Files
notify-bridge/packages/server/src/notify_bridge_server/api/email_bots.py
T
alexei.dolgolyov 920920bc67
Build and Test / test-frontend (push) Successful in 9m37s
Build and Test / test-backend (push) Successful in 10m53s
Build and Test / build-image (push) Failing after 14m52s
feat: production-readiness hardening across security, async, DB, ops
Security
- SSRF: async DNS resolver; allow_redirects=False on all outbound clients;
  matrix homeserver_url validated on create/update/test; update_provider
  and email_bot merge incoming config and reject ***-masked secrets.
- Auth: bcrypt offloaded to asyncio.to_thread; JWT now carries iss/aud +
  leeway and rejects missing claims; setup TOCTOU closed inside a
  transaction; rate limits extended (default 600/min, 10/min on password
  change, 30/min on needs-setup); constant-time login to prevent username
  enumeration.
- Config: rejects known dev secret keys; validates CORS origin schemes,
  port range, token lifetimes.
- Webhook handlers stream-read body with a 1 MiB cap; Discord 429 retries
  bounded (3 attempts, Retry-After capped at 60 s).
- CSP + HSTS added to SecurityHeadersMiddleware.

Async / runtime
- SQLite engine: WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout,
  pool_pre_ping, dispose on shutdown.
- Lifespan shutdown now stops scheduler before closing HTTP session and
  disposing the engine.
- Shared aiohttp session locked against concurrent first-caller races;
  core NotificationDispatcher accepts and reuses it.
- Storage and scheduled backup writes wrapped in asyncio.to_thread.
- NUT client writes bounded by asyncio.wait_for.
- Telegram poller switched from 3 s short-poll to 30 s interval + 25 s
  long-poll (~10x fewer API calls).

Database
- New performance-indexes migration covers every FK/owner column and
  hot-path composite (notification_tracker(provider_id, enabled);
  event_log(user_id, created_at DESC); webhook_payload_log(provider_id,
  created_at DESC); action_execution(action_id, started_at DESC)).
- New schema_version table for future upgrade gating.
- __system__ placeholder user (id=0) seeded so user_id=0 system defaults
  satisfy the newly enforced FK; filtered out of /auth/needs-setup,
  /api/users, and setup.
- list_notification_trackers rewritten to batched loads (was 1+N+N*M).
- Retention job extended to event_log, webhook_payload_log, and
  action_execution; retention days exposed as a setting.

Scheduler
- AsyncIOScheduler job_defaults: coalesce, misfire_grace_time=300,
  max_instances=1.

Ops
- uvicorn runs with proxy_headers, forwarded_allow_ips,
  timeout_graceful_shutdown; access log suppressed in non-debug.
- FastAPI version string now reads from importlib.metadata.
- New /api/ready endpoint separate from /api/health.
- docker-compose drops the ALLOW_PRIVATE_URLS=1 default, adds mem/cpu/pid
  limits, read_only + tmpfs, cap_drop:ALL, no-new-privileges; healthcheck
  targets /api/ready.
- CI now runs on push/PR with backend pytest, frontend svelte-check +
  build, and a non-push image build; release workflow gated on tests,
  publishes immutable sha-<commit> image tag, adds Trivy scan.

Tests
- New packages/server/tests/ with 29 passing tests: config validation,
  JWT round-trip + aud/alg=none rejection, SSRF scheme and private-range
  enforcement (sync + async), Discord bounded retry, and a lifespan-level
  /api/health + /api/ready smoke check.
- Renamed the misnamed services/test_dispatch.py to manual_dispatch.py so
  pytest never auto-collects production code.

Frontend
- /login now redirects already-authenticated users to /, shows a distinct
  'backend unreachable' banner (en/ru) when /auth/needs-setup fails.
2026-04-23 19:44:56 +03:00

174 lines
5.2 KiB
Python

"""Email bot management API routes."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import EmailBot, User
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/email-bots", tags=["email-bots"])
class EmailBotCreate(BaseModel):
name: str
icon: str = ""
email: str
smtp_host: str
smtp_port: int = 587
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = True
class EmailBotUpdate(BaseModel):
name: str | None = None
icon: str | None = None
email: str | None = None
smtp_host: str | None = None
smtp_port: int | None = None
smtp_username: str | None = None
smtp_password: str | None = None
smtp_use_tls: bool | None = None
@router.get("")
async def list_email_bots(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
result = await session.exec(
select(EmailBot).where(EmailBot.user_id == user.id)
)
return [_response(b) for b in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_email_bot(
body: EmailBotCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
# Verify SMTP connection before saving
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
client = EmailClient(SmtpConfig(
host=body.smtp_host, port=body.smtp_port,
username=body.smtp_username, password=body.smtp_password,
from_address=body.email, from_name=body.name,
use_tls=body.smtp_use_tls,
))
result = await client.verify_connection()
if not result.get("success"):
raise HTTPException(status_code=400, detail=f"SMTP connection failed: {result.get('error', 'Unknown error')}")
bot = EmailBot(user_id=user.id, **body.model_dump())
session.add(bot)
await session.commit()
await session.refresh(bot)
return _response(bot)
@router.get("/{bot_id}")
async def get_email_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = await _get_user_bot(session, bot_id, user.id)
return _response(bot)
@router.put("/{bot_id}")
async def update_email_bot(
bot_id: int,
body: EmailBotUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
bot = await _get_user_bot(session, bot_id, user.id)
updates = body.model_dump(exclude_unset=True)
# Reject the masked value the GET response returns so the stored password
# is preserved if the user saves without retyping it.
if "smtp_password" in updates:
pw = updates["smtp_password"]
if isinstance(pw, str) and pw.startswith("***"):
updates.pop("smtp_password")
for field, value in updates.items():
setattr(bot, field, value)
session.add(bot)
await session.commit()
await session.refresh(bot)
return _response(bot)
@router.delete("/{bot_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_email_bot(
bot_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from .delete_protection import check_email_bot, raise_if_used
bot = await _get_user_bot(session, bot_id, user.id)
raise_if_used(await check_email_bot(session, bot.id), bot.name)
await session.delete(bot)
await session.commit()
@router.post("/{bot_id}/test")
async def test_email_bot(
bot_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test email to the bot's own address to verify SMTP connection."""
bot = await _get_user_bot(session, bot_id, user.id)
from ..services.notifier import _get_test_message
msg = _get_test_message(locale, "email")
from notify_bridge_core.notifications.email.client import EmailClient, SmtpConfig
client = EmailClient(SmtpConfig(
host=bot.smtp_host,
port=bot.smtp_port,
username=bot.smtp_username,
password=bot.smtp_password,
from_address=bot.email,
from_name=bot.name,
use_tls=bot.smtp_use_tls,
))
result = await client.send(
to_email=bot.email,
subject=f"Notify Bridge — {msg}",
body_text=msg,
)
return result
def _response(bot: EmailBot) -> dict:
return {
"id": bot.id,
"name": bot.name,
"icon": bot.icon,
"email": bot.email,
"smtp_host": bot.smtp_host,
"smtp_port": bot.smtp_port,
"smtp_username": bot.smtp_username,
"smtp_password": "***" if bot.smtp_password else "",
"smtp_use_tls": bot.smtp_use_tls,
"created_at": bot.created_at.isoformat(),
}
async def _get_user_bot(session: AsyncSession, bot_id: int, user_id: int) -> EmailBot:
return await get_owned_entity(
session, EmailBot, bot_id, user_id, not_found_msg="Email bot not found",
)