diff --git a/backend/alembic/versions/0006_email_from_name.py b/backend/alembic/versions/0006_email_from_name.py new file mode 100644 index 0000000..86bc8c9 --- /dev/null +++ b/backend/alembic/versions/0006_email_from_name.py @@ -0,0 +1,23 @@ +"""add from_name to email settings + +Revision ID: 0006_email_from_name +Revises: 0005_target_owners +Create Date: 2026-02-12 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0006_email_from_name" +down_revision = "0005_target_owners" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("email_notification_settings", sa.Column("from_name", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + op.drop_column("email_notification_settings", "from_name") diff --git a/backend/app/api/routes/admin_settings.py b/backend/app/api/routes/admin_settings.py index d51e9fc..158b768 100644 --- a/backend/app/api/routes/admin_settings.py +++ b/backend/app/api/routes/admin_settings.py @@ -1,4 +1,5 @@ from email.message import EmailMessage +from email.utils import formataddr import smtplib import ssl @@ -34,6 +35,7 @@ def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut: smtp_host=settings.smtp_host, smtp_port=settings.smtp_port, smtp_username=settings.smtp_username, + from_name=settings.from_name, from_email=settings.from_email, use_starttls=settings.use_starttls, use_ssl=settings.use_ssl, @@ -64,6 +66,7 @@ async def update_email_settings( settings.smtp_host = payload.smtp_host.strip() if payload.smtp_host else None settings.smtp_port = payload.smtp_port settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None + settings.from_name = payload.from_name.strip() if payload.from_name else None settings.from_email = str(payload.from_email) if payload.from_email else None settings.use_starttls = payload.use_starttls settings.use_ssl = payload.use_ssl @@ -94,7 +97,7 @@ async def test_email_settings( password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None message = EmailMessage() - message["From"] = settings.from_email + message["From"] = formataddr((settings.from_name, settings.from_email)) if settings.from_name else settings.from_email message["To"] = str(payload.recipient) message["Subject"] = payload.subject message.set_content(payload.message) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 962925e..4131338 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -130,6 +130,7 @@ class EmailNotificationSettings(Base): smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587) smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, nullable=True) + from_name: Mapped[str | None] = mapped_column(String(255), nullable=True) from_email: Mapped[str | None] = mapped_column(String(255), nullable=True) use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/backend/app/schemas/admin_settings.py b/backend/app/schemas/admin_settings.py index 8a8c734..7427294 100644 --- a/backend/app/schemas/admin_settings.py +++ b/backend/app/schemas/admin_settings.py @@ -8,6 +8,7 @@ class EmailSettingsOut(BaseModel): smtp_host: str | None smtp_port: int smtp_username: str | None + from_name: str | None from_email: EmailStr | None use_starttls: bool use_ssl: bool @@ -23,6 +24,7 @@ class EmailSettingsUpdate(BaseModel): smtp_username: str | None = None smtp_password: str | None = None clear_smtp_password: bool = False + from_name: str | None = None from_email: EmailStr | None = None use_starttls: bool = True use_ssl: bool = False diff --git a/backend/app/services/alert_notifications.py b/backend/app/services/alert_notifications.py index e0a0071..6280ec2 100644 --- a/backend/app/services/alert_notifications.py +++ b/backend/app/services/alert_notifications.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from datetime import datetime, timedelta, timezone from email.message import EmailMessage +from email.utils import formataddr import smtplib import ssl @@ -21,6 +22,7 @@ async def _smtp_send( port: int, username: str | None, password: str | None, + from_name: str | None, from_email: str, recipient: str, subject: str, @@ -30,7 +32,7 @@ async def _smtp_send( ) -> None: def _send() -> None: message = EmailMessage() - message["From"] = from_email + message["From"] = formataddr((from_name, from_email)) if from_name else from_email message["To"] = recipient message["Subject"] = subject message.set_content(body) @@ -132,6 +134,7 @@ async def process_target_owner_notifications(db: AsyncSession, status: AlertStat port=settings.smtp_port, username=settings.smtp_username, password=password, + from_name=settings.from_name, from_email=settings.from_email, recipient=recipient, subject=subject, diff --git a/frontend/src/pages/AdminUsersPage.jsx b/frontend/src/pages/AdminUsersPage.jsx index 230c671..d4dda3e 100644 --- a/frontend/src/pages/AdminUsersPage.jsx +++ b/frontend/src/pages/AdminUsersPage.jsx @@ -13,6 +13,7 @@ export function AdminUsersPage() { smtp_username: "", smtp_password: "", clear_smtp_password: false, + from_name: "", from_email: "", use_starttls: true, use_ssl: false, @@ -37,6 +38,7 @@ export function AdminUsersPage() { smtp_username: smtp.smtp_username || "", smtp_password: "", clear_smtp_password: false, + from_name: smtp.from_name || "", from_email: smtp.from_email || "", use_starttls: !!smtp.use_starttls, use_ssl: !!smtp.use_ssl, @@ -85,6 +87,7 @@ export function AdminUsersPage() { ...emailSettings, smtp_host: emailSettings.smtp_host.trim() || null, smtp_username: emailSettings.smtp_username.trim() || null, + from_name: emailSettings.from_name.trim() || null, from_email: emailSettings.from_email.trim() || null, smtp_password: emailSettings.smtp_password || null, alert_recipients: recipients, @@ -249,6 +252,14 @@ export function AdminUsersPage() { /> +
+ + setEmailSettings({ ...emailSettings, from_name: e.target.value })} + /> +
id !== userId) : [...ids, userId]; +} + +function OwnerPicker({ candidates, selectedIds, onToggle, query, onQueryChange }) { + const filtered = candidates.filter((item) => + item.email.toLowerCase().includes(query.trim().toLowerCase()) + ); + const selected = candidates.filter((item) => selectedIds.includes(item.user_id)); + + return ( +
+
+ {selected.length > 0 ? ( + selected.map((item) => ( + + )) + ) : ( + No owners selected yet. + )} +
+ onQueryChange(e.target.value)} + placeholder="Search users by email..." + /> +
+ {filtered.map((item) => { + const active = selectedIds.includes(item.user_id); + return ( + + ); + })} + {filtered.length === 0 &&
No matching users.
} +
+
+ ); +} + export function TargetsPage() { const { tokens, refresh, me } = useAuth(); const [targets, setTargets] = useState([]); @@ -40,6 +98,8 @@ export function TargetsPage() { const [testState, setTestState] = useState({ loading: false, message: "", ok: null }); const [saveState, setSaveState] = useState({ loading: false, message: "" }); const [ownerCandidates, setOwnerCandidates] = useState([]); + const [createOwnerQuery, setCreateOwnerQuery] = useState(""); + const [editOwnerQuery, setEditOwnerQuery] = useState(""); const canManage = me?.role === "admin" || me?.role === "operator"; @@ -117,6 +177,7 @@ export function TargetsPage() { const startEdit = (target) => { setEditing(true); setSaveState({ loading: false, message: "" }); + setEditOwnerQuery(""); setEditForm({ id: target.id, name: target.name, @@ -231,27 +292,13 @@ export function TargetsPage() {
-
- {ownerCandidates.map((candidate) => { - const checked = form.owner_user_ids.includes(candidate.user_id); - return ( - - ); - })} - {ownerCandidates.length === 0 && No users available.} -
+ setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })} + /> Only selected users will receive email notifications for this target's alerts.
@@ -322,27 +369,13 @@ export function TargetsPage() {
-
- {ownerCandidates.map((candidate) => { - const checked = editForm.owner_user_ids.includes(candidate.user_id); - return ( - - ); - })} - {ownerCandidates.length === 0 && No users available.} -
+ setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })} + /> Only selected users will receive email notifications for this target's alerts.
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 60e6093..18b0890 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -749,34 +749,74 @@ button { gap: 10px; } -.owner-grid { - display: flex; - flex-wrap: wrap; +.owner-picker { + display: grid; gap: 8px; } -.owner-chip { +.owner-selected { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-height: 28px; +} + +.owner-selected-chip { display: inline-flex; align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 10px; - border: 1px solid #315a8d; - background: #10284d; - color: #d9e8fb; + gap: 8px; + border-radius: 999px; + padding: 5px 10px; + border: 1px solid #4a8fd3; + background: #184679; + color: #e8f4ff; font-size: 12px; font-weight: 600; } -.owner-chip.active { - border-color: #52c7f8; - background: #174377; +.owner-search-input { + width: 100%; } -.owner-chip input { - margin: 0; - width: 14px; - height: 14px; +.owner-search-results { + display: grid; + gap: 6px; + max-height: 150px; + overflow: auto; + padding-right: 2px; +} + +.owner-result { + width: 100%; + text-align: left; + border-radius: 10px; + border: 1px solid #2d5d95; + background: #112c52; + color: #d9ebff; + padding: 7px 10px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.owner-result small { + color: #9db9dc; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.05em; +} + +.owner-result.active { + border-color: #52c7f8; + background: #184679; +} + +.owner-result-empty { + border: 1px dashed #365e8e; + border-radius: 10px; + padding: 8px 10px; + color: #9db9dc; + font-size: 12px; } .primary-btn {