Skip to content

feat: email notification delivery via SMTP (#961)#971

Open
rmusser01 wants to merge 1 commit intodevfrom
fix/issue-961-email-delivery
Open

feat: email notification delivery via SMTP (#961)#971
rmusser01 wants to merge 1 commit intodevfrom
fix/issue-961-email-delivery

Conversation

@rmusser01
Copy link
Copy Markdown
Owner

Summary

Closes #961. Adds SMTP-based email delivery for notifications.

What's included

  • email_delivery.py — SMTP email sender with:
    • TLS support (STARTTLS)
    • Multipart emails (plain text + HTML)
    • Severity-colored HTML template (info=blue, warning=amber, error=red)
    • Deep link to notification details
    • Graceful error handling (logs failures, doesn't crash)
  • .env.example — SMTP configuration section with commented defaults

Configuration

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM_ADDRESS=noreply@tldw.local
SMTP_USE_TLS=true

Follow-up needed

  • Hook into jobs_notifications_service to send emails alongside in-app notifications
  • Add user email address to notification preferences
  • Email digest mode (batch notifications into periodic summaries)
  • Web Push API support (separate effort)

Test plan

  • Configure SMTP in .env → is_email_delivery_configured() returns True
  • Call send_notification_email() → email received with HTML template
  • No SMTP configured → function returns False silently
  • Invalid SMTP credentials → error logged, function returns False

🤖 Generated with Claude Code

Add SMTP-based email delivery for notifications so self-hosters
receive alerts when not actively using the app.

email_delivery.py:
- send_notification_email() — sends via SMTP with TLS support
- format_notification_email() — converts notification to email
  with severity-colored HTML template
- is_email_delivery_configured() — check if SMTP is available
- Configured via env: SMTP_HOST, SMTP_PORT, SMTP_USER,
  SMTP_PASSWORD, SMTP_FROM_ADDRESS, SMTP_USE_TLS

.env.example:
- Added SMTP configuration section with commented defaults

Follow-up needed:
- Hook into jobs_notifications_service to send emails on notification
  creation (check user email preference + SMTP availability)
- Add user email address storage to notification preferences
- Web Push API support (separate effort)

Closes #961

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: f7073544-abd3-4009-a958-849436fe55dc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-961-email-delivery

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new email notification delivery module using SMTP, along with the necessary configuration updates in the .env file. The review identified several critical issues: the synchronous implementation conflicts with the asynchronous usage example in the docstring, there is a potential XSS vulnerability due to unescaped HTML content, and the port configuration lacks robust error handling. Additionally, the code contains redundant logic in the SMTP connection flow and an unused parameter in the formatting function.

return _get_smtp_config() is not None


def send_notification_email(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function is defined as synchronous (def), but the usage example in the module docstring (line 12) shows it being awaited (await). If this function is intended to be used within an asynchronous context (e.g., a FastAPI endpoint), it will block the event loop during network I/O, which can severely impact performance. Consider defining it as async def and using asyncio.to_thread for the smtplib calls, or using an asynchronous library like aiosmtplib.

Comment on lines +128 to +137
body_html = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
<div style="border-left: 4px solid {severity_color}; padding: 16px; margin: 16px 0; background: #f9fafb; border-radius: 0 8px 8px 0;">
<h2 style="margin: 0 0 8px 0; font-size: 16px; color: #111827;">{title}</h2>
<p style="margin: 0; color: #6b7280; font-size: 14px;">{message}</p>
</div>
{"<p><a href='" + link_url + "' style='color: " + severity_color + "; text-decoration: none; font-weight: 500;'>View details →</a></p>" if link_url else ""}
<p style="color: #9ca3af; font-size: 12px; margin-top: 24px;">Sent by tldw server</p>
</div>
"""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The HTML body is constructed using f-strings without escaping the title, message, or link_url variables. This creates a potential XSS vulnerability if these values contain malicious HTML or scripts, especially since they may originate from external scraped content. Use html.escape to sanitize these inputs.

return None
return {
"host": host,
"port": int(os.environ.get("SMTP_PORT", "587")),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Directly calling int() on an environment variable without validation will raise a ValueError if the value is non-numeric. It's safer to handle this gracefully with a default value.

Suggested change
"port": int(os.environ.get("SMTP_PORT", "587")),
"port": int(os.environ.get("SMTP_PORT", "587")) if os.environ.get("SMTP_PORT", "587").isdigit() else 587,

Comment on lines +76 to +87
if config["use_tls"]:
context = ssl.create_default_context()
with smtplib.SMTP(str(config["host"]), int(config["port"])) as server:
server.starttls(context=context)
if config["user"] and config["password"]:
server.login(str(config["user"]), str(config["password"]))
server.sendmail(str(config["from_address"]), to, msg.as_string())
else:
with smtplib.SMTP(str(config["host"]), int(config["port"])) as server:
if config["user"] and config["password"]:
server.login(str(config["user"]), str(config["password"]))
server.sendmail(str(config["from_address"]), to, msg.as_string())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is significant code duplication between the TLS and non-TLS branches. The logic can be simplified by moving the shared connection and login steps outside the conditional blocks.

        with smtplib.SMTP(str(config["host"]), config["port"]) as server:
            if config["use_tls"]:
                server.starttls(context=ssl.create_default_context())
            if config["user"] and config["password"]:
                server.login(str(config["user"]), str(config["password"]))
            server.sendmail(str(config["from_address"]), to, msg.as_string())

# ---------------------------------------------------------------------------

def format_notification_email(
kind: str,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The parameter kind is defined but never used within the function body.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tldw_Server_API/app/core/Notifications/email_delivery.py`:
- Around line 128-135: The HTML template injects raw title, message and link_url
into body_html; escape title and message with an HTML-escaping utility (e.g.,
html.escape) before interpolation and also sanitize severity_color if it's
derived from user input; validate and normalize link_url using a URL parser
(e.g., urllib.parse) and only allow safe schemes (http, https, mailto) and safe
hosts/ports, then use the escaped normalized URL for the anchor href and text;
update the body_html construction (where title, message, link_url are used) to
interpolate the escaped values and drop any raw user-supplied content without
validation.
- Around line 31-43: The SMTP port parsing in _get_smtp_config can raise
ValueError and should be treated as a handled "not configured" case instead of
letting exceptions propagate; wrap the int(os.environ.get("SMTP_PORT", "587"))
parse in a try/except (catch ValueError) and if parsing fails (or the parsed
port is out of 1–65535 range) return None (or otherwise surface a controlled
config-error object) so is_email_delivery_configured() and
send_notification_email() continue to see an unconfigured SMTP setup rather than
crash; update _get_smtp_config accordingly and keep callers' existing
None-handling behavior.
- Around line 89-92: The success and error logs in the email sending block (the
logger.info and logger.error calls in email_delivery.py inside the send/notify
function) are currently recording raw recipient addresses and subjects; change
them to avoid PII by either logging a generated/internal notification_id or a
masked recipient (e.g., hash or show only first char + domain) and remove the
subject from logs. Update the code paths that call logger.info(f"Notification
email sent to {to}: {subject}") and logger.error(f"Failed to send notification
email to {to}: {exc}") to instead log the safe identifier (notification_id or
masked_to) plus the exception text where appropriate, ensuring no raw email
addresses or subject content are written to logs. Ensure notification_id is
created/returned by the send function or passed in from the caller so it can be
used in both success and error logs.

In `@tldw_Server_API/Config_Files/.env.example`:
- Around line 201-208: The SMTP variables in .env.example are misnamed and don't
match the live mailer used by NotificationsService.deliver_email (which calls
_email_service.send_email) and the AuthNZ backend that expects SMTP_USERNAME and
EMAIL_FROM; either rename the example keys to SMTP_USERNAME and EMAIL_FROM (and
SMTP_PASSWORD/SMTP_HOST/SMTP_PORT/SMTP_USE_TLS as appropriate) to match the
backend, or update/centralize the mailer wiring so NotificationsService uses the
new keys; locate NotificationsService.deliver_email and the email backend in
app/core/AuthNZ/email_service.py (and the _email_service reference) and make the
env var names consistent across both places.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2e9cfe47-feaa-4ec6-b38c-88ea2ba67f42

📥 Commits

Reviewing files that changed from the base of the PR and between 6fd9312 and df1036d.

📒 Files selected for processing (2)
  • tldw_Server_API/Config_Files/.env.example
  • tldw_Server_API/app/core/Notifications/email_delivery.py

Comment on lines +31 to +43
def _get_smtp_config() -> dict[str, str | int | bool] | None:
"""Read SMTP config from environment. Returns None if not configured."""
host = os.environ.get("SMTP_HOST", "").strip()
if not host:
return None
return {
"host": host,
"port": int(os.environ.get("SMTP_PORT", "587")),
"user": os.environ.get("SMTP_USER", "").strip(),
"password": os.environ.get("SMTP_PASSWORD", "").strip(),
"from_address": os.environ.get("SMTP_FROM_ADDRESS", "noreply@tldw.local").strip(),
"use_tls": os.environ.get("SMTP_USE_TLS", "true").lower() in ("true", "1", "yes"),
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat malformed SMTP config as a handled failure, not an exception.

int(os.environ.get("SMTP_PORT", "587")) can raise inside _get_smtp_config(), which means both is_email_delivery_configured() and send_notification_email() can blow up on a bad SMTP_PORT. That breaks the module’s advertised graceful-failure behavior. Validate the port here and return None or a handled config error when parsing fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tldw_Server_API/app/core/Notifications/email_delivery.py` around lines 31 -
43, The SMTP port parsing in _get_smtp_config can raise ValueError and should be
treated as a handled "not configured" case instead of letting exceptions
propagate; wrap the int(os.environ.get("SMTP_PORT", "587")) parse in a
try/except (catch ValueError) and if parsing fails (or the parsed port is out of
1–65535 range) return None (or otherwise surface a controlled config-error
object) so is_email_delivery_configured() and send_notification_email() continue
to see an unconfigured SMTP setup rather than crash; update _get_smtp_config
accordingly and keep callers' existing None-handling behavior.

Comment on lines +89 to +92
logger.info(f"Notification email sent to {to}: {subject}")
return True
except Exception as exc:
logger.error(f"Failed to send notification email to {to}: {exc}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stop logging recipient addresses and subjects.

Both paths persist raw email addresses, and the success log also records notification content via subject. That leaks PII and potentially sensitive alert data into application logs; log a masked recipient or an internal notification ID instead. As per coding guidelines, "Never log sensitive information (API keys, passwords)".

🧰 Tools
🪛 Ruff (0.15.7)

[warning] 90-90: Consider moving this statement to an else block

(TRY300)


[warning] 91-91: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tldw_Server_API/app/core/Notifications/email_delivery.py` around lines 89 -
92, The success and error logs in the email sending block (the logger.info and
logger.error calls in email_delivery.py inside the send/notify function) are
currently recording raw recipient addresses and subjects; change them to avoid
PII by either logging a generated/internal notification_id or a masked recipient
(e.g., hash or show only first char + domain) and remove the subject from logs.
Update the code paths that call logger.info(f"Notification email sent to {to}:
{subject}") and logger.error(f"Failed to send notification email to {to}:
{exc}") to instead log the safe identifier (notification_id or masked_to) plus
the exception text where appropriate, ensuring no raw email addresses or subject
content are written to logs. Ensure notification_id is created/returned by the
send function or passed in from the caller so it can be used in both success and
error logs.

Comment on lines +128 to +135
body_html = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 600px; margin: 0 auto;">
<div style="border-left: 4px solid {severity_color}; padding: 16px; margin: 16px 0; background: #f9fafb; border-radius: 0 8px 8px 0;">
<h2 style="margin: 0 0 8px 0; font-size: 16px; color: #111827;">{title}</h2>
<p style="margin: 0; color: #6b7280; font-size: 14px;">{message}</p>
</div>
{"<p><a href='" + link_url + "' style='color: " + severity_color + "; text-decoration: none; font-weight: 500;'>View details →</a></p>" if link_url else ""}
<p style="color: #9ca3af; font-size: 12px; margin-top: 24px;">Sent by tldw server</p>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Escape and validate the HTML fields before interpolation.

title, message, and link_url are injected straight into the HTML template. Any notification text containing markup or quotes can rewrite the rendered email body or anchor target. HTML-escape the text fields and only render links after normalizing/restricting them to expected schemes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tldw_Server_API/app/core/Notifications/email_delivery.py` around lines 128 -
135, The HTML template injects raw title, message and link_url into body_html;
escape title and message with an HTML-escaping utility (e.g., html.escape)
before interpolation and also sanitize severity_color if it's derived from user
input; validate and normalize link_url using a URL parser (e.g., urllib.parse)
and only allow safe schemes (http, https, mailto) and safe hosts/ports, then use
the escaped normalized URL for the anchor href and text; update the body_html
construction (where title, message, link_url are used) to interpolate the
escaped values and drop any raw user-supplied content without validation.

Comment on lines +201 to +208
# Configure to receive notification emails (watchlist alerts, job failures).
# Without SMTP, notifications are only visible in-app.
#SMTP_HOST=smtp.gmail.com
#SMTP_PORT=587
#SMTP_USER=your-email@gmail.com
#SMTP_PASSWORD=your-app-password
#SMTP_FROM_ADDRESS=noreply@tldw.local
#SMTP_USE_TLS=true
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This SMTP block does not match the notification sender the app currently uses.

NotificationsService.deliver_email() in tldw_Server_API/app/core/Notifications/service.py, Lines 39-84 still routes mail through _email_service.send_email(...), and the existing backend in tldw_Server_API/app/core/AuthNZ/email_service.py, Lines 367-377 reads SMTP_USERNAME/EMAIL_FROM, not SMTP_USER/SMTP_FROM_ADDRESS. As written, operators can fill in this new section and still leave the live notification path unconfigured. Align these names with the existing backend or wire notifications to this new helper before documenting this as the SMTP setup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tldw_Server_API/Config_Files/.env.example` around lines 201 - 208, The SMTP
variables in .env.example are misnamed and don't match the live mailer used by
NotificationsService.deliver_email (which calls _email_service.send_email) and
the AuthNZ backend that expects SMTP_USERNAME and EMAIL_FROM; either rename the
example keys to SMTP_USERNAME and EMAIL_FROM (and
SMTP_PASSWORD/SMTP_HOST/SMTP_PORT/SMTP_USE_TLS as appropriate) to match the
backend, or update/centralize the mailer wiring so NotificationsService uses the
new keys; locate NotificationsService.deliver_email and the email backend in
app/core/AuthNZ/email_service.py (and the _email_service reference) and make the
env var names consistent across both places.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant