Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ docker-compose*.yml
.env
.env.*
!.env.example

# Local operator/developer configs. These contain real secret_key, jwt_secret
# and SSO encryption keys, and must never be baked into an image.
incidentrelay.conf
*.local.conf
openspec/
.kiro/
.claude/
13 changes: 13 additions & 0 deletions app/api/schemas/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
CHANNEL_TYPE_PATTERN,
LARK_CHANNEL,
SLACK_CHANNEL,
SLACK_MENTION_CONFIG_KEYS,
WEBHOOK_STYLE_CHANNELS,
)
from app.notifiers.email.email_templates import normalize_email_html_template
Expand Down Expand Up @@ -62,6 +63,18 @@ def validate_config(self):
config["chat_id"] = chat_id

if self.channel_type == SLACK_CHANNEL:
# Mention flags apply to both webhook and Bot API mode, so they
# are validated before the mode-specific branches and are never
# stripped by them.
for mention_key in SLACK_MENTION_CONFIG_KEYS:
if mention_key not in config:
continue

if not isinstance(config[mention_key], bool):
raise ValueError(
f"slack {mention_key} must be a boolean"
)

configured_mode = str(config.get("mode") or "").strip()

if configured_mode:
Expand Down
82 changes: 82 additions & 0 deletions app/modules/smtp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Shared SMTP transport for all IncidentRelay email delivery paths.

Every email IncidentRelay sends goes through the globally configured
``[smtp]`` server. This module owns the connect / STARTTLS / AUTH / send
sequence so that the alert email channel, stakeholder notifications and
incident participant notifications cannot drift apart in how they
validate configuration or report transport errors.
"""

import smtplib
from email.message import EmailMessage

from app.settings import Config

SMTP_TIMEOUT_SECONDS = 10

MISSING_HOST_ERROR = "smtp host is missing: set [smtp] host in config"
PARTIAL_AUTH_ERROR = (
"SMTP auth is partially configured: both [smtp] user and password "
"must be set, or both must be empty"
)
AUTH_UNSUPPORTED_ERROR = (
"SMTP auth is configured, but the SMTP server does not support AUTH. "
"Clear [smtp] user/password for an unauthenticated relay, or use an "
"SMTP server that supports AUTH."
)


def build_plain_message(recipient, subject, body):
"""Build a plain-text email message addressed to one recipient."""
message = EmailMessage()
message["Subject"] = subject
message["From"] = Config.SMTP_FROM
message["To"] = recipient
message.set_content(body)

return message


def send_email_message(message):
"""Send a prepared email message through the configured SMTP server.

Raises RuntimeError when SMTP is not configured, when authentication is
only partially configured, or when credentials are supplied to a server
that does not advertise AUTH.
"""
smtp_host = Config.SMTP_HOST

if not smtp_host:
raise RuntimeError(MISSING_HOST_ERROR)

smtp_port = int(Config.SMTP_PORT)

username = (Config.SMTP_USER or "").strip()
password = Config.SMTP_PASSWORD or ""

with smtplib.SMTP(
smtp_host,
smtp_port,
timeout=SMTP_TIMEOUT_SECONDS,
) as smtp:
smtp.ehlo()

if Config.SMTP_USE_TLS:
smtp.starttls()
smtp.ehlo()

if username or password:
if not username or not password:
raise RuntimeError(PARTIAL_AUTH_ERROR)

if not smtp.has_extn("auth"):
raise RuntimeError(AUTH_UNSUPPORTED_ERROR)

smtp.login(username, password)

smtp.send_message(message)


def send_plain_email(recipient, subject, body):
"""Build and send a plain-text email to one recipient."""
send_email_message(build_plain_message(recipient, subject, body))
36 changes: 2 additions & 34 deletions app/notifiers/email/notifier.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import smtplib
from email.message import EmailMessage

from app import Config
from app.modules.smtp import send_email_message
from app.notifiers.base import BaseNotifier
from app.notifiers.email.email_templates import render_email_html
from app.services.alerts.priority import format_alert_title_with_priority
Expand All @@ -17,12 +17,6 @@ def send(self, channel, alert, text, event_type="notification"):
config = channel.config or {}
recipient = self._recipient_email(alert)

smtp_host = Config.SMTP_HOST
smtp_port = int(Config.SMTP_PORT)

if not smtp_host:
raise RuntimeError("smtp host is missing: set [smtp] host in config")

message = EmailMessage()
message["Subject"] = f"[On-call] {format_alert_title_with_priority(alert)}"
message["From"] = Config.SMTP_FROM
Expand All @@ -37,33 +31,7 @@ def send(self, channel, alert, text, event_type="notification"):
)
message.add_alternative(html_body, subtype="html")

username = (Config.SMTP_USER or "").strip()
password = Config.SMTP_PASSWORD or ""

with smtplib.SMTP(smtp_host, smtp_port, timeout=10) as smtp:
smtp.ehlo()

if Config.SMTP_USE_TLS:
smtp.starttls()
smtp.ehlo()

if username or password:
if not username or not password:
raise RuntimeError(
"SMTP auth is partially configured: both [smtp] user and password "
"must be set, or both must be empty"
)

if not smtp.has_extn("auth"):
raise RuntimeError(
"SMTP auth is configured, but the SMTP server does not support AUTH. "
"Clear [smtp] user/password for an unauthenticated relay, or use an "
"SMTP server that supports AUTH."
)

smtp.login(username, password)

smtp.send_message(message)
send_email_message(message)

return {
"provider": self.name,
Expand Down
Loading
Loading