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
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:
23
backend/alembic/versions/0006_email_from_name.py
Normal file
23
backend/alembic/versions/0006_email_from_name.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</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">
|
||||
<label>From email</label>
|
||||
<input
|
||||
|
||||
@@ -29,6 +29,64 @@ const emptyEditForm = {
|
||||
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() {
|
||||
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() {
|
||||
</div>
|
||||
<div className="field field-full">
|
||||
<label>Responsible Users (Target Owners)</label>
|
||||
<div className="owner-grid">
|
||||
{ownerCandidates.map((candidate) => {
|
||||
const checked = form.owner_user_ids.includes(candidate.user_id);
|
||||
return (
|
||||
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
|
||||
<input
|
||||
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>
|
||||
<OwnerPicker
|
||||
candidates={ownerCandidates}
|
||||
selectedIds={form.owner_user_ids}
|
||||
query={createOwnerQuery}
|
||||
onQueryChange={setCreateOwnerQuery}
|
||||
onToggle={(userId) => setForm({ ...form, owner_user_ids: toggleOwner(form.owner_user_ids, userId) })}
|
||||
/>
|
||||
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||
</div>
|
||||
<div className="field submit-field field-full">
|
||||
@@ -322,27 +369,13 @@ export function TargetsPage() {
|
||||
</div>
|
||||
<div className="field field-full">
|
||||
<label>Responsible Users (Target Owners)</label>
|
||||
<div className="owner-grid">
|
||||
{ownerCandidates.map((candidate) => {
|
||||
const checked = editForm.owner_user_ids.includes(candidate.user_id);
|
||||
return (
|
||||
<label key={candidate.user_id} className={`owner-chip ${checked ? "active" : ""}`}>
|
||||
<input
|
||||
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>
|
||||
<OwnerPicker
|
||||
candidates={ownerCandidates}
|
||||
selectedIds={editForm.owner_user_ids}
|
||||
query={editOwnerQuery}
|
||||
onQueryChange={setEditOwnerQuery}
|
||||
onToggle={(userId) => setEditForm({ ...editForm, owner_user_ids: toggleOwner(editForm.owner_user_ids, userId) })}
|
||||
/>
|
||||
<small>Only selected users will receive email notifications for this target's alerts.</small>
|
||||
</div>
|
||||
<div className="field submit-field field-full">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user