diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 2fb774e..4b474cb 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -14,6 +14,7 @@ from app.services.config_page import ( is_ticktick_oauth_ready, save_config_updates, ) +from app.services.email import EmailConfigurationError, EmailDeliveryError, is_smtp_ready, send_smtp_test_email from sqlalchemy.orm import Session templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -33,6 +34,49 @@ def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | return None, None +def _smtp_test_notice(status_value: str | None) -> tuple[str | None, str | None]: + if status_value == "success": + return "SMTP test email sent successfully.", None + if status_value == "config-error": + return None, "SMTP test failed. Check required SMTP settings before sending a test email." + if status_value == "failed": + return None, "SMTP test failed. Check saved SMTP settings and server reachability." + return None, None + + +def _build_config_context( + *, + auth_db_session: Session, + settings: Settings, + current_auth: AuthenticatedSession, + config_saved: bool, + config_error: str | None, + password_change_error: str | None, + ticktick_oauth_notice: str | None, + ticktick_oauth_error: str | None, + smtp_test_notice: str | None, + smtp_test_error: str | None, +) -> dict[str, object]: + return { + "app_name": settings.app_name, + "app_env": settings.app_env, + "current_username": current_auth.user.username, + "csrf_token": current_auth.session.csrf_token, + "force_password_change": current_auth.user.force_password_change, + "password_change_error": password_change_error, + "config_error": config_error, + "config_saved": config_saved, + "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, + "ticktick_oauth_notice": ticktick_oauth_notice, + "ticktick_oauth_error": ticktick_oauth_error, + "smtp_test_ready": is_smtp_ready(settings), + "smtp_test_notice": smtp_test_notice, + "smtp_test_error": smtp_test_error, + } + + @router.get("/", response_class=HTMLResponse) def home( request: Request, @@ -66,22 +110,19 @@ def config_page( ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( request.query_params.get("ticktick_oauth") ) - - context = { - "app_name": settings.app_name, - "app_env": settings.app_env, - "current_username": current_auth.user.username, - "csrf_token": current_auth.session.csrf_token, - "force_password_change": current_auth.user.force_password_change, - "password_change_error": None, - "config_error": None, - "config_saved": request.query_params.get("saved") == "1", - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": ticktick_oauth_notice, - "ticktick_oauth_error": ticktick_oauth_error, - } + smtp_test_notice, smtp_test_error = _smtp_test_notice(request.query_params.get("smtp_test")) + context = _build_config_context( + auth_db_session=auth_db_session, + settings=settings, + current_auth=current_auth, + config_saved=request.query_params.get("saved") == "1", + config_error=None, + password_change_error=None, + ticktick_oauth_notice=ticktick_oauth_notice, + ticktick_oauth_error=ticktick_oauth_error, + smtp_test_notice=smtp_test_notice, + smtp_test_error=smtp_test_error, + ) return templates.TemplateResponse(request, "config.html", context) @@ -99,21 +140,18 @@ async def config_submit( csrf_token = form.get("csrf_token") if csrf_token != current_auth.session.csrf_token: logger.warning("Rejected config update due to CSRF validation failure") - context = { - "app_name": settings.app_name, - "app_env": settings.app_env, - "current_username": current_auth.user.username, - "csrf_token": current_auth.session.csrf_token, - "force_password_change": current_auth.user.force_password_change, - "password_change_error": None, - "config_error": "invalid config update request", - "config_saved": False, - "config_sections": build_config_sections(auth_db_session, settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), - "ticktick_redirect_uri": settings.ticktick_redirect_uri, - "ticktick_oauth_notice": None, - "ticktick_oauth_error": None, - } + context = _build_config_context( + auth_db_session=auth_db_session, + settings=settings, + current_auth=current_auth, + config_saved=False, + config_error="invalid config update request", + password_change_error=None, + ticktick_oauth_notice=None, + ticktick_oauth_error=None, + smtp_test_notice=None, + smtp_test_error=None, + ) return templates.TemplateResponse( request, "config.html", @@ -126,21 +164,18 @@ async def config_submit( except ConfigSaveError: logger.warning("Rejected config update due to invalid submitted values") refreshed_settings = get_settings() - context = { - "app_name": refreshed_settings.app_name, - "app_env": refreshed_settings.app_env, - "current_username": current_auth.user.username, - "csrf_token": current_auth.session.csrf_token, - "force_password_change": current_auth.user.force_password_change, - "password_change_error": None, - "config_error": "invalid config submission", - "config_saved": False, - "config_sections": build_config_sections(auth_db_session, refreshed_settings), - "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), - "ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri, - "ticktick_oauth_notice": None, - "ticktick_oauth_error": None, - } + context = _build_config_context( + auth_db_session=auth_db_session, + settings=refreshed_settings, + current_auth=current_auth, + config_saved=False, + config_error="invalid config submission", + password_change_error=None, + ticktick_oauth_notice=None, + ticktick_oauth_error=None, + smtp_test_notice=None, + smtp_test_error=None, + ) return templates.TemplateResponse( request, "config.html", @@ -149,3 +184,57 @@ async def config_submit( ) return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/config/smtp/test", response_class=HTMLResponse) +async def smtp_test_submit( + request: Request, + auth_db_session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + form = await request.form() + csrf_token = form.get("csrf_token") + if csrf_token != current_auth.session.csrf_token: + logger.warning("Rejected SMTP test due to CSRF validation failure") + context = _build_config_context( + auth_db_session=auth_db_session, + settings=settings, + current_auth=current_auth, + config_saved=False, + config_error=None, + password_change_error=None, + ticktick_oauth_notice=None, + ticktick_oauth_error=None, + smtp_test_notice=None, + smtp_test_error="invalid SMTP test request", + ) + return templates.TemplateResponse( + request, + "config.html", + context, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + try: + send_smtp_test_email(settings) + except EmailConfigurationError as exc: + logger.warning("SMTP test email rejected due to configuration: %s", exc) + return RedirectResponse( + url="/config?smtp_test=config-error", + status_code=status.HTTP_303_SEE_OTHER, + ) + except EmailDeliveryError as exc: + logger.warning("SMTP test email failed: %s", exc) + return RedirectResponse( + url="/config?smtp_test=failed", + status_code=status.HTTP_303_SEE_OTHER, + ) + + return RedirectResponse( + url="/config?smtp_test=success", + status_code=status.HTTP_303_SEE_OTHER, + ) diff --git a/app/config.py b/app/config.py index 1d7e0b9..929b38a 100644 --- a/app/config.py +++ b/app/config.py @@ -23,6 +23,14 @@ class Settings(BaseSettings): home_assistant_auth_token: str = "" home_assistant_timeout_seconds: float = 1.0 home_assistant_action_task_project_id: str = "" + smtp_enabled: bool = False + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_from_address: str = "" + smtp_to_address: str = "" + smtp_use_starttls: bool = True poo_webhook_id: str = "" poo_sensor_entity_name: str = "sensor.test_poo_status" poo_sensor_friendly_name: str = "Poo Status" diff --git a/app/services/config_page.py b/app/services/config_page.py index 5a68621..141f75d 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -27,6 +27,14 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( ConfigField("System", "APP_ENV", "app_env", "App Env"), ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"), ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"), + ConfigField("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled"), + ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"), + ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"), + ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"), + ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True), + ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"), + ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"), + ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"), ConfigField( "Authentication", "AUTH_SESSION_COOKIE_NAME", @@ -260,6 +268,14 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "home_assistant_auth_token": settings.home_assistant_auth_token, "home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds, "home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id, + "smtp_enabled": settings.smtp_enabled, + "smtp_host": settings.smtp_host, + "smtp_port": settings.smtp_port, + "smtp_username": settings.smtp_username, + "smtp_password": settings.smtp_password, + "smtp_from_address": settings.smtp_from_address, + "smtp_to_address": settings.smtp_to_address, + "smtp_use_starttls": settings.smtp_use_starttls, "poo_webhook_id": settings.poo_webhook_id, "poo_sensor_entity_name": settings.poo_sensor_entity_name, "poo_sensor_friendly_name": settings.poo_sensor_friendly_name, diff --git a/app/services/email.py b/app/services/email.py new file mode 100644 index 0000000..249b921 --- /dev/null +++ b/app/services/email.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from dataclasses import dataclass +from email.message import EmailMessage +import smtplib + +from app.config import Settings + + +class EmailConfigurationError(ValueError): + """Raised when SMTP settings are incomplete or disabled.""" + + +class EmailDeliveryError(RuntimeError): + """Raised when sending email fails.""" + + +@dataclass(frozen=True, slots=True) +class SMTPConfig: + host: str + port: int + username: str + password: str + from_address: str + to_address: str + use_starttls: bool + + +def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig: + if require_enabled and not settings.smtp_enabled: + raise EmailConfigurationError("SMTP is disabled") + + if not settings.smtp_host: + raise EmailConfigurationError("SMTP host is required") + + if settings.smtp_port <= 0: + raise EmailConfigurationError("SMTP port must be greater than zero") + + if not settings.smtp_from_address: + raise EmailConfigurationError("SMTP from address is required") + + if not settings.smtp_to_address: + raise EmailConfigurationError("SMTP to address is required") + + return SMTPConfig( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=settings.smtp_password, + from_address=settings.smtp_from_address, + to_address=settings.smtp_to_address, + use_starttls=settings.smtp_use_starttls, + ) + + +def is_smtp_ready(settings: Settings) -> bool: + try: + get_smtp_config(settings, require_enabled=False) + except EmailConfigurationError: + return False + return True + + +def send_plaintext_email( + settings: Settings, + *, + subject: str, + body: str, + recipient: str | None = None, + require_enabled: bool = True, +) -> None: + smtp_config = get_smtp_config(settings, require_enabled=require_enabled) + message = EmailMessage() + message["Subject"] = subject + message["From"] = smtp_config.from_address + message["To"] = recipient or smtp_config.to_address + message.set_content(body) + + try: + with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp: + smtp.ehlo() + if smtp_config.use_starttls: + smtp.starttls() + smtp.ehlo() + if smtp_config.username: + smtp.login(smtp_config.username, smtp_config.password) + smtp.send_message(message) + except (OSError, smtplib.SMTPException) as exc: + error_message = _sanitize_error_message(str(exc), smtp_config.password) + raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc + + +def send_smtp_test_email(settings: Settings) -> None: + send_plaintext_email( + settings, + subject="Home Automation SMTP Test", + body="This is a test email from Home Automation SMTP settings.", + require_enabled=False, + ) + + +def _sanitize_error_message(message: str, password: str) -> str: + sanitized = message + if password: + sanitized = sanitized.replace(password, "[redacted]") + return sanitized \ No newline at end of file diff --git a/app/templates/config.html b/app/templates/config.html index 6ce1b81..0fb3f70 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -33,6 +33,14 @@