add smtp module and testing
This commit is contained in:
+135
-46
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -33,6 +33,14 @@
|
||||
<div class="notice">{{ ticktick_oauth_notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if smtp_test_error %}
|
||||
<div class="alert">{{ smtp_test_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if smtp_test_notice %}
|
||||
<div class="notice">{{ smtp_test_notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta single-column">
|
||||
<div>
|
||||
<dt>当前用户</dt>
|
||||
@@ -102,6 +110,20 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.name == "SMTP" %}
|
||||
<div class="integration-action-row">
|
||||
<div>
|
||||
<p class="integration-action-title">SMTP Test Email</p>
|
||||
<p class="integration-action-copy">Save the SMTP settings first, then send a simple plaintext test email to the configured recipient.</p>
|
||||
</div>
|
||||
{% if smtp_test_ready %}
|
||||
<button type="submit" formaction="/config/smtp/test" formmethod="post">Send SMTP Test</button>
|
||||
{% else %}
|
||||
<span class="button-link disabled" aria-disabled="true">Send SMTP Test</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user