Add support for "from_name" field in email notifications
All checks were successful
PostgreSQL Compatibility Matrix / PG14 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG15 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG16 smoke (push) Successful in 7s
PostgreSQL Compatibility Matrix / PG17 smoke (push) Successful in 6s
PostgreSQL Compatibility Matrix / PG18 smoke (push) Successful in 6s

Introduced a new optional "from_name" attribute to email settings, allowing customization of the sender's display name in outgoing emails. Updated backend models, APIs, and front-end components to include and handle this field properly. This enhances email clarity and personalization for users.
This commit is contained in:
2026-02-12 15:31:03 +01:00
parent ea26ef4d33
commit 648ff07651
8 changed files with 177 additions and 61 deletions

View File

@@ -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")

View File

@@ -1,4 +1,5 @@
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr
import smtplib import smtplib
import ssl import ssl
@@ -34,6 +35,7 @@ def _to_out(settings: EmailNotificationSettings) -> EmailSettingsOut:
smtp_host=settings.smtp_host, smtp_host=settings.smtp_host,
smtp_port=settings.smtp_port, smtp_port=settings.smtp_port,
smtp_username=settings.smtp_username, smtp_username=settings.smtp_username,
from_name=settings.from_name,
from_email=settings.from_email, from_email=settings.from_email,
use_starttls=settings.use_starttls, use_starttls=settings.use_starttls,
use_ssl=settings.use_ssl, 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_host = payload.smtp_host.strip() if payload.smtp_host else None
settings.smtp_port = payload.smtp_port settings.smtp_port = payload.smtp_port
settings.smtp_username = payload.smtp_username.strip() if payload.smtp_username else None 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.from_email = str(payload.from_email) if payload.from_email else None
settings.use_starttls = payload.use_starttls settings.use_starttls = payload.use_starttls
settings.use_ssl = payload.use_ssl 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 password = decrypt_secret(settings.encrypted_smtp_password) if settings.encrypted_smtp_password else None
message = EmailMessage() 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["To"] = str(payload.recipient)
message["Subject"] = payload.subject message["Subject"] = payload.subject
message.set_content(payload.message) message.set_content(payload.message)

View File

@@ -130,6 +130,7 @@ class EmailNotificationSettings(Base):
smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587) smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, default=587)
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True) smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
encrypted_smtp_password: Mapped[str | None] = mapped_column(Text, 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) from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) use_starttls: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) use_ssl: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -8,6 +8,7 @@ class EmailSettingsOut(BaseModel):
smtp_host: str | None smtp_host: str | None
smtp_port: int smtp_port: int
smtp_username: str | None smtp_username: str | None
from_name: str | None
from_email: EmailStr | None from_email: EmailStr | None
use_starttls: bool use_starttls: bool
use_ssl: bool use_ssl: bool
@@ -23,6 +24,7 @@ class EmailSettingsUpdate(BaseModel):
smtp_username: str | None = None smtp_username: str | None = None
smtp_password: str | None = None smtp_password: str | None = None
clear_smtp_password: bool = False clear_smtp_password: bool = False
from_name: str | None = None
from_email: EmailStr | None = None from_email: EmailStr | None = None
use_starttls: bool = True use_starttls: bool = True
use_ssl: bool = False use_ssl: bool = False

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr
import smtplib import smtplib
import ssl import ssl
@@ -21,6 +22,7 @@ async def _smtp_send(
port: int, port: int,
username: str | None, username: str | None,
password: str | None, password: str | None,
from_name: str | None,
from_email: str, from_email: str,
recipient: str, recipient: str,
subject: str, subject: str,
@@ -30,7 +32,7 @@ async def _smtp_send(
) -> None: ) -> None:
def _send() -> None: def _send() -> None:
message = EmailMessage() message = EmailMessage()
message["From"] = from_email message["From"] = formataddr((from_name, from_email)) if from_name else from_email
message["To"] = recipient message["To"] = recipient
message["Subject"] = subject message["Subject"] = subject
message.set_content(body) message.set_content(body)
@@ -132,6 +134,7 @@ async def process_target_owner_notifications(db: AsyncSession, status: AlertStat
port=settings.smtp_port, port=settings.smtp_port,
username=settings.smtp_username, username=settings.smtp_username,
password=password, password=password,
from_name=settings.from_name,
from_email=settings.from_email, from_email=settings.from_email,
recipient=recipient, recipient=recipient,
subject=subject, subject=subject,

View File

@@ -13,6 +13,7 @@ export function AdminUsersPage() {
smtp_username: "", smtp_username: "",
smtp_password: "", smtp_password: "",
clear_smtp_password: false, clear_smtp_password: false,
from_name: "",
from_email: "", from_email: "",
use_starttls: true, use_starttls: true,
use_ssl: false, use_ssl: false,
@@ -37,6 +38,7 @@ export function AdminUsersPage() {
smtp_username: smtp.smtp_username || "", smtp_username: smtp.smtp_username || "",
smtp_password: "", smtp_password: "",
clear_smtp_password: false, clear_smtp_password: false,
from_name: smtp.from_name || "",
from_email: smtp.from_email || "", from_email: smtp.from_email || "",
use_starttls: !!smtp.use_starttls, use_starttls: !!smtp.use_starttls,
use_ssl: !!smtp.use_ssl, use_ssl: !!smtp.use_ssl,
@@ -85,6 +87,7 @@ export function AdminUsersPage() {
...emailSettings, ...emailSettings,
smtp_host: emailSettings.smtp_host.trim() || null, smtp_host: emailSettings.smtp_host.trim() || null,
smtp_username: emailSettings.smtp_username.trim() || null, smtp_username: emailSettings.smtp_username.trim() || null,
from_name: emailSettings.from_name.trim() || null,
from_email: emailSettings.from_email.trim() || null, from_email: emailSettings.from_email.trim() || null,
smtp_password: emailSettings.smtp_password || null, smtp_password: emailSettings.smtp_password || null,
alert_recipients: recipients, alert_recipients: recipients,
@@ -249,6 +252,14 @@ export function AdminUsersPage() {
/> />
</div> </div>
<div className="admin-field">
<label>From name</label>
<input
value={emailSettings.from_name}
placeholder="NexaPG Alerts"
onChange={(e) => setEmailSettings({ ...emailSettings, from_name: e.target.value })}
/>
</div>
<div className="admin-field"> <div className="admin-field">
<label>From email</label> <label>From email</label>
<input <input

View File

@@ -29,6 +29,64 @@ const emptyEditForm = {
owner_user_ids: [], owner_user_ids: [],
}; };
function toggleOwner(ids, userId) {
return ids.includes(userId) ? ids.filter((id) => 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 (
<div className="owner-picker">
<div className="owner-selected">
{selected.length > 0 ? (
selected.map((item) => (
<button
key={`selected-${item.user_id}`}
type="button"
className="owner-selected-chip"
onClick={() => onToggle(item.user_id)}
title="Remove owner"
>
<span>{item.email}</span>
<span aria-hidden="true">x</span>
</button>
))
) : (
<span className="muted">No owners selected yet.</span>
)}
</div>
<input
type="text"
className="owner-search-input"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search users by email..."
/>
<div className="owner-search-results">
{filtered.map((item) => {
const active = selectedIds.includes(item.user_id);
return (
<button
key={item.user_id}
type="button"
className={`owner-result ${active ? "active" : ""}`}
onClick={() => onToggle(item.user_id)}
>
<span>{item.email}</span>
<small>{item.role}</small>
</button>
);
})}
{filtered.length === 0 && <div className="owner-result-empty">No matching users.</div>}
</div>
</div>
);
}
export function TargetsPage() { export function TargetsPage() {
const { tokens, refresh, me } = useAuth(); const { tokens, refresh, me } = useAuth();
const [targets, setTargets] = useState([]); const [targets, setTargets] = useState([]);
@@ -40,6 +98,8 @@ export function TargetsPage() {
const [testState, setTestState] = useState({ loading: false, message: "", ok: null }); const [testState, setTestState] = useState({ loading: false, message: "", ok: null });
const [saveState, setSaveState] = useState({ loading: false, message: "" }); const [saveState, setSaveState] = useState({ loading: false, message: "" });
const [ownerCandidates, setOwnerCandidates] = useState([]); const [ownerCandidates, setOwnerCandidates] = useState([]);
const [createOwnerQuery, setCreateOwnerQuery] = useState("");
const [editOwnerQuery, setEditOwnerQuery] = useState("");
const canManage = me?.role === "admin" || me?.role === "operator"; const canManage = me?.role === "admin" || me?.role === "operator";
@@ -117,6 +177,7 @@ export function TargetsPage() {
const startEdit = (target) => { const startEdit = (target) => {
setEditing(true); setEditing(true);
setSaveState({ loading: false, message: "" }); setSaveState({ loading: false, message: "" });
setEditOwnerQuery("");
setEditForm({ setEditForm({
id: target.id, id: target.id,
name: target.name, name: target.name,
@@ -231,27 +292,13 @@ export function TargetsPage() {
</div> </div>
<div className="field field-full"> <div className="field field-full">
<label>Responsible Users (Target Owners)</label> <label>Responsible Users (Target Owners)</label>
<div className="owner-grid"> <OwnerPicker
{ownerCandidates.map((candidate) => { candidates={ownerCandidates}
const checked = form.owner_user_ids.includes(candidate.user_id); selectedIds={form.owner_user_ids}
return ( query={createOwnerQuery}
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}> onQueryChange={setCreateOwnerQuery}
<input onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
type="checkbox" />
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...form.owner_user_ids, candidate.user_id]
: form.owner_user_ids.filter((id) => id !== candidate.user_id);
setForm({ ...form, owner_user_ids: next });
}}
/>
<span>{candidate.email}</span>
</label>
);
})}
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
</div>
<small>Only selected users will receive email notifications for this target's alerts.</small> <small>Only selected users will receive email notifications for this target's alerts.</small>
</div> </div>
<div className="field submit-field field-full"> <div className="field submit-field field-full">
@@ -322,27 +369,13 @@ export function TargetsPage() {
</div> </div>
<div className="field field-full"> <div className="field field-full">
<label>Responsible Users (Target Owners)</label> <label>Responsible Users (Target Owners)</label>
<div className="owner-grid"> <OwnerPicker
{ownerCandidates.map((candidate) => { candidates={ownerCandidates}
const checked = editForm.owner_user_ids.includes(candidate.user_id); selectedIds={editForm.owner_user_ids}
return ( query={editOwnerQuery}
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}> onQueryChange={setEditOwnerQuery}
<input onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
type="checkbox" />
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...editForm.owner_user_ids, candidate.user_id]
: editForm.owner_user_ids.filter((id) => id !== candidate.user_id);
setEditForm({ ...editForm, owner_user_ids: next });
}}
/>
<span>{candidate.email}</span>
</label>
);
})}
{ownerCandidates.length === 0 && <small className="muted">No users available.</small>}
</div>
<small>Only selected users will receive email notifications for this target's alerts.</small> <small>Only selected users will receive email notifications for this target's alerts.</small>
</div> </div>
<div className="field submit-field field-full"> <div className="field submit-field field-full">

View File

@@ -749,34 +749,74 @@ button {
gap: 10px; gap: 10px;
} }
.owner-grid { .owner-picker {
display: flex; display: grid;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.owner-chip { .owner-selected {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 28px;
}
.owner-selected-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
padding: 6px 10px; border-radius: 999px;
border-radius: 10px; padding: 5px 10px;
border: 1px solid #315a8d; border: 1px solid #4a8fd3;
background: #10284d; background: #184679;
color: #d9e8fb; color: #e8f4ff;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
} }
.owner-chip.active { .owner-search-input {
border-color: #52c7f8; width: 100%;
background: #174377;
} }
.owner-chip input { .owner-search-results {
margin: 0; display: grid;
width: 14px; gap: 6px;
height: 14px; 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 { .primary-btn {