|
|
|
@@ -6,9 +6,12 @@ import json
|
|
|
|
import logging
|
|
|
|
import logging
|
|
|
|
import re
|
|
|
|
import re
|
|
|
|
import smtplib
|
|
|
|
import smtplib
|
|
|
|
|
|
|
|
from functools import lru_cache
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
from email.generator import BytesGenerator
|
|
|
|
from email.generator import BytesGenerator
|
|
|
|
from email.message import EmailMessage
|
|
|
|
from email.message import EmailMessage
|
|
|
|
from email.utils import formataddr
|
|
|
|
from email.policy import SMTP as SMTP_POLICY
|
|
|
|
|
|
|
|
from email.utils import formataddr, formatdate, make_msgid
|
|
|
|
from io import BytesIO
|
|
|
|
from io import BytesIO
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
|
|
|
|
|
|
@@ -25,6 +28,7 @@ EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
|
|
PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
|
|
|
PLACEHOLDER_PATTERN = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
|
|
|
|
EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>")
|
|
|
|
EXCHANGE_MESSAGE_ID_PATTERN = re.compile(r"<([^>]+)>")
|
|
|
|
EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)")
|
|
|
|
EXCHANGE_INTERNAL_ID_PATTERN = re.compile(r"\[InternalId=([^\],]+)")
|
|
|
|
|
|
|
|
EMAIL_LOGO_CID = "magent-logo"
|
|
|
|
|
|
|
|
|
|
|
|
TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
|
|
|
|
TEMPLATE_METADATA: Dict[str, Dict[str, Any]] = {
|
|
|
|
"invited": {
|
|
|
|
"invited": {
|
|
|
|
@@ -64,6 +68,183 @@ TEMPLATE_PLACEHOLDERS = [
|
|
|
|
"username",
|
|
|
|
"username",
|
|
|
|
]
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
EMAIL_TAGLINE = "Find and fix media requests fast."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
EMAIL_TONE_STYLES: Dict[str, Dict[str, str]] = {
|
|
|
|
|
|
|
|
"brand": {
|
|
|
|
|
|
|
|
"chip_bg": "rgba(255, 107, 43, 0.16)",
|
|
|
|
|
|
|
|
"chip_border": "rgba(255, 107, 43, 0.38)",
|
|
|
|
|
|
|
|
"chip_text": "#ffd2bf",
|
|
|
|
|
|
|
|
"accent_a": "#ff6b2b",
|
|
|
|
|
|
|
|
"accent_b": "#1c6bff",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"success": {
|
|
|
|
|
|
|
|
"chip_bg": "rgba(34, 197, 94, 0.16)",
|
|
|
|
|
|
|
|
"chip_border": "rgba(34, 197, 94, 0.38)",
|
|
|
|
|
|
|
|
"chip_text": "#c7f9d7",
|
|
|
|
|
|
|
|
"accent_a": "#22c55e",
|
|
|
|
|
|
|
|
"accent_b": "#1c6bff",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"warning": {
|
|
|
|
|
|
|
|
"chip_bg": "rgba(251, 146, 60, 0.16)",
|
|
|
|
|
|
|
|
"chip_border": "rgba(251, 146, 60, 0.38)",
|
|
|
|
|
|
|
|
"chip_text": "#ffe0ba",
|
|
|
|
|
|
|
|
"accent_a": "#fb923c",
|
|
|
|
|
|
|
|
"accent_b": "#ff6b2b",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"danger": {
|
|
|
|
|
|
|
|
"chip_bg": "rgba(248, 113, 113, 0.16)",
|
|
|
|
|
|
|
|
"chip_border": "rgba(248, 113, 113, 0.38)",
|
|
|
|
|
|
|
|
"chip_text": "#ffd0d0",
|
|
|
|
|
|
|
|
"accent_a": "#ef4444",
|
|
|
|
|
|
|
|
"accent_b": "#ff6b2b",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
TEMPLATE_PRESENTATION: Dict[str, Dict[str, str]] = {
|
|
|
|
|
|
|
|
"invited": {
|
|
|
|
|
|
|
|
"tone": "brand",
|
|
|
|
|
|
|
|
"title": "You have been invited",
|
|
|
|
|
|
|
|
"subtitle": "A new account invitation is ready for you.",
|
|
|
|
|
|
|
|
"primary_label": "Accept invite",
|
|
|
|
|
|
|
|
"primary_url_key": "invite_link",
|
|
|
|
|
|
|
|
"secondary_label": "How it works",
|
|
|
|
|
|
|
|
"secondary_url_key": "how_it_works_url",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"welcome": {
|
|
|
|
|
|
|
|
"tone": "success",
|
|
|
|
|
|
|
|
"title": "Welcome to Magent",
|
|
|
|
|
|
|
|
"subtitle": "Your account is ready and synced.",
|
|
|
|
|
|
|
|
"primary_label": "Open Magent",
|
|
|
|
|
|
|
|
"primary_url_key": "app_url",
|
|
|
|
|
|
|
|
"secondary_label": "How it works",
|
|
|
|
|
|
|
|
"secondary_url_key": "how_it_works_url",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"warning": {
|
|
|
|
|
|
|
|
"tone": "warning",
|
|
|
|
|
|
|
|
"title": "Account warning",
|
|
|
|
|
|
|
|
"subtitle": "Please review the note below.",
|
|
|
|
|
|
|
|
"primary_label": "Open Magent",
|
|
|
|
|
|
|
|
"primary_url_key": "app_url",
|
|
|
|
|
|
|
|
"secondary_label": "How it works",
|
|
|
|
|
|
|
|
"secondary_url_key": "how_it_works_url",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"banned": {
|
|
|
|
|
|
|
|
"tone": "danger",
|
|
|
|
|
|
|
|
"title": "Account status changed",
|
|
|
|
|
|
|
|
"subtitle": "Your account has been restricted or removed.",
|
|
|
|
|
|
|
|
"primary_label": "How it works",
|
|
|
|
|
|
|
|
"primary_url_key": "how_it_works_url",
|
|
|
|
|
|
|
|
"secondary_label": "",
|
|
|
|
|
|
|
|
"secondary_url_key": "",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_stat_card(label: str, value: str, detail: str = "") -> str:
|
|
|
|
|
|
|
|
detail_html = (
|
|
|
|
|
|
|
|
f"<div style=\"margin-top:8px; font-size:13px; line-height:1.6; color:#5c687d; word-break:break-word;\">"
|
|
|
|
|
|
|
|
f"{html.escape(detail)}</div>"
|
|
|
|
|
|
|
|
if detail
|
|
|
|
|
|
|
|
else ""
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"border-collapse:separate; background:#f8fafc; border:1px solid #d9e2ef; border-radius:16px;\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:16px; font-family:Segoe UI, Arial, sans-serif; color:#132033;\">"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#6b778c; margin-bottom:8px;\">"
|
|
|
|
|
|
|
|
f"{html.escape(label)}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:20px; font-weight:800; line-height:1.45; word-break:break-word; color:#132033;\">"
|
|
|
|
|
|
|
|
f"{html.escape(value)}</div>"
|
|
|
|
|
|
|
|
f"{detail_html}"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_stat_grid(cards: list[str]) -> str:
|
|
|
|
|
|
|
|
if not cards:
|
|
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
rows: list[str] = []
|
|
|
|
|
|
|
|
for index in range(0, len(cards), 2):
|
|
|
|
|
|
|
|
left = cards[index]
|
|
|
|
|
|
|
|
right = cards[index + 1] if index + 1 < len(cards) else ""
|
|
|
|
|
|
|
|
rows.append(
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
f"<td width=\"50%\" style=\"vertical-align:top; padding:0 5px 10px 0;\">{left}</td>"
|
|
|
|
|
|
|
|
f"<td width=\"50%\" style=\"vertical-align:top; padding:0 0 10px 5px;\">{right}</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"border-collapse:collapse; margin:0 0 18px;\">"
|
|
|
|
|
|
|
|
f"{''.join(rows)}"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_list(items: list[str], *, ordered: bool = False) -> str:
|
|
|
|
|
|
|
|
tag = "ol" if ordered else "ul"
|
|
|
|
|
|
|
|
marker = "padding-left:20px;" if ordered else "padding-left:18px;"
|
|
|
|
|
|
|
|
rendered_items = "".join(
|
|
|
|
|
|
|
|
f"<li style=\"margin:0 0 8px;\">{html.escape(item)}</li>" for item in items if item
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
f"<{tag} style=\"margin:0; {marker} color:#132033; line-height:1.8; font-size:14px;\">"
|
|
|
|
|
|
|
|
f"{rendered_items}"
|
|
|
|
|
|
|
|
f"</{tag}>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral") -> str:
|
|
|
|
|
|
|
|
styles = {
|
|
|
|
|
|
|
|
"neutral": {
|
|
|
|
|
|
|
|
"background": "#f8fafc",
|
|
|
|
|
|
|
|
"border": "#d9e2ef",
|
|
|
|
|
|
|
|
"eyebrow": "#6b778c",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"brand": {
|
|
|
|
|
|
|
|
"background": "#eef4ff",
|
|
|
|
|
|
|
|
"border": "#bfd2ff",
|
|
|
|
|
|
|
|
"eyebrow": "#2754b6",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"success": {
|
|
|
|
|
|
|
|
"background": "#edf9f0",
|
|
|
|
|
|
|
|
"border": "#bfe4c6",
|
|
|
|
|
|
|
|
"eyebrow": "#1f7a3f",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"warning": {
|
|
|
|
|
|
|
|
"background": "#fff5ea",
|
|
|
|
|
|
|
|
"border": "#ffd5a8",
|
|
|
|
|
|
|
|
"eyebrow": "#c46a10",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
"danger": {
|
|
|
|
|
|
|
|
"background": "#fff0f0",
|
|
|
|
|
|
|
|
"border": "#f3c1c1",
|
|
|
|
|
|
|
|
"eyebrow": "#bb2d2d",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
}.get(variant, {
|
|
|
|
|
|
|
|
"background": "#f8fafc",
|
|
|
|
|
|
|
|
"border": "#d9e2ef",
|
|
|
|
|
|
|
|
"eyebrow": "#6b778c",
|
|
|
|
|
|
|
|
"text": "#132033",
|
|
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
f"style=\"border-collapse:separate; margin:0 0 18px; background:{styles['background']}; "
|
|
|
|
|
|
|
|
f"border:1px solid {styles['border']}; border-radius:18px;\">"
|
|
|
|
|
|
|
|
f"<tr><td style=\"padding:18px; font-family:Segoe UI, Arial, sans-serif; color:{styles['text']};\">"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:{styles['eyebrow']}; margin-bottom:10px;\">"
|
|
|
|
|
|
|
|
f"{html.escape(title)}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:14px; line-height:1.8; color:{styles['text']};\">{body_html}</div>"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
"invited": {
|
|
|
|
"invited": {
|
|
|
|
"subject": "{{app_name}} invite for {{recipient_email}}",
|
|
|
|
"subject": "{{app_name}} invite for {{recipient_email}}",
|
|
|
|
@@ -81,18 +262,43 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
"Build: {{build_number}}\n"
|
|
|
|
"Build: {{build_number}}\n"
|
|
|
|
),
|
|
|
|
),
|
|
|
|
"body_html": (
|
|
|
|
"body_html": (
|
|
|
|
"<h1>You have been invited</h1>"
|
|
|
|
"<div style=\"margin:0 0 20px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
"<p>You have been invited to <strong>{{app_name}}</strong>.</p>"
|
|
|
|
"A new invitation has been prepared for <strong>{{recipient_email}}</strong>. Use the details below to sign up."
|
|
|
|
"<p><strong>Invite code:</strong> {{invite_code}}<br />"
|
|
|
|
"</div>"
|
|
|
|
"<strong>Invited by:</strong> {{inviter_username}}<br />"
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
"<strong>Invite label:</strong> {{invite_label}}<br />"
|
|
|
|
[
|
|
|
|
"<strong>Expires:</strong> {{invite_expires_at}}<br />"
|
|
|
|
_build_email_stat_card("Invite code", "{{invite_code}}"),
|
|
|
|
"<strong>Remaining uses:</strong> {{invite_remaining_uses}}</p>"
|
|
|
|
_build_email_stat_card("Invited by", "{{inviter_username}}"),
|
|
|
|
"<p>{{invite_description}}</p>"
|
|
|
|
_build_email_stat_card("Invite label", "{{invite_label}}"),
|
|
|
|
"<p>{{message}}</p>"
|
|
|
|
_build_email_stat_card(
|
|
|
|
"<p><a href=\"{{invite_link}}\">Accept invite and create account</a></p>"
|
|
|
|
"Access window",
|
|
|
|
"<p><a href=\"{{how_it_works_url}}\">How it works</a></p>"
|
|
|
|
"{{invite_expires_at}}",
|
|
|
|
"<p class=\"meta\">Build {{build_number}}</p>"
|
|
|
|
"Remaining uses: {{invite_remaining_uses}}",
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Invitation details",
|
|
|
|
|
|
|
|
"<div style=\"white-space:pre-line;\">{{invite_description}}</div>",
|
|
|
|
|
|
|
|
variant="brand",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Message from admin",
|
|
|
|
|
|
|
|
"<div style=\"white-space:pre-line;\">{{message}}</div>",
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What happens next",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Open the invite link and complete the signup flow.",
|
|
|
|
|
|
|
|
"Sign in using the shared credentials for Magent and Seerr.",
|
|
|
|
|
|
|
|
"Use the How it works page if you want a quick overview first.",
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
ordered=True,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
),
|
|
|
|
),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"welcome": {
|
|
|
|
"welcome": {
|
|
|
|
@@ -106,12 +312,34 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
"{{message}}\n"
|
|
|
|
"{{message}}\n"
|
|
|
|
),
|
|
|
|
),
|
|
|
|
"body_html": (
|
|
|
|
"body_html": (
|
|
|
|
"<h1>Welcome</h1>"
|
|
|
|
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
"<p>Your {{app_name}} account is ready, <strong>{{username}}</strong>.</p>"
|
|
|
|
"Your account is live and ready to use. Everything below mirrors the current site behavior."
|
|
|
|
"<p><strong>Role:</strong> {{role}}</p>"
|
|
|
|
"</div>"
|
|
|
|
"<p><a href=\"{{app_url}}\">Open {{app_name}}</a><br />"
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
"<a href=\"{{how_it_works_url}}\">Read how it works</a></p>"
|
|
|
|
[
|
|
|
|
"<p>{{message}}</p>"
|
|
|
|
_build_email_stat_card("Username", "{{username}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Role", "{{role}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Magent", "{{app_url}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Guides", "{{how_it_works_url}}"),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What to do next",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Open Magent and sign in using your shared credentials.",
|
|
|
|
|
|
|
|
"Search all requests or review your own activity without refreshing the page.",
|
|
|
|
|
|
|
|
"Use the invite tools in your profile if your account allows it.",
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
ordered=True,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="success",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Additional notes",
|
|
|
|
|
|
|
|
"<div style=\"white-space:pre-line;\">{{message}}</div>",
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
),
|
|
|
|
),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"warning": {
|
|
|
|
"warning": {
|
|
|
|
@@ -124,12 +352,38 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
"If you need help, contact the admin.\n"
|
|
|
|
"If you need help, contact the admin.\n"
|
|
|
|
),
|
|
|
|
),
|
|
|
|
"body_html": (
|
|
|
|
"body_html": (
|
|
|
|
"<h1>Account warning</h1>"
|
|
|
|
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
"<p>Hello <strong>{{username}}</strong>,</p>"
|
|
|
|
"Please review this account notice carefully. This message was sent by an administrator."
|
|
|
|
"<p>This is a warning regarding your {{app_name}} account.</p>"
|
|
|
|
"</div>"
|
|
|
|
"<p><strong>Reason:</strong> {{reason}}</p>"
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
"<p>{{message}}</p>"
|
|
|
|
[
|
|
|
|
"<p>If you need help, contact the admin.</p>"
|
|
|
|
_build_email_stat_card("Account", "{{username}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Role", "{{role}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Application", "{{app_name}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Support", "{{how_it_works_url}}"),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Reason",
|
|
|
|
|
|
|
|
"<div style=\"font-size:18px; font-weight:800; line-height:1.6; white-space:pre-line;\">{{reason}}</div>",
|
|
|
|
|
|
|
|
variant="warning",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Administrator note",
|
|
|
|
|
|
|
|
"<div style=\"white-space:pre-line;\">{{message}}</div>",
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What to do next",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Review the note above and confirm you understand what needs to change.",
|
|
|
|
|
|
|
|
"If you need help, reply through your usual support path or contact an administrator.",
|
|
|
|
|
|
|
|
"Keep this email for reference until the matter is resolved.",
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
),
|
|
|
|
),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
"banned": {
|
|
|
|
"banned": {
|
|
|
|
@@ -141,11 +395,38 @@ DEFAULT_TEMPLATES: Dict[str, Dict[str, str]] = {
|
|
|
|
"{{message}}\n"
|
|
|
|
"{{message}}\n"
|
|
|
|
),
|
|
|
|
),
|
|
|
|
"body_html": (
|
|
|
|
"body_html": (
|
|
|
|
"<h1>Account status changed</h1>"
|
|
|
|
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
"<p>Hello <strong>{{username}}</strong>,</p>"
|
|
|
|
"Your account access has changed. Review the details below."
|
|
|
|
"<p>Your {{app_name}} account has been banned or removed.</p>"
|
|
|
|
"</div>"
|
|
|
|
"<p><strong>Reason:</strong> {{reason}}</p>"
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
"<p>{{message}}</p>"
|
|
|
|
[
|
|
|
|
|
|
|
|
_build_email_stat_card("Account", "{{username}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Status", "Restricted"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Application", "{{app_name}}"),
|
|
|
|
|
|
|
|
_build_email_stat_card("Guidance", "{{how_it_works_url}}"),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Reason",
|
|
|
|
|
|
|
|
"<div style=\"font-size:18px; font-weight:800; line-height:1.6; white-space:pre-line;\">{{reason}}</div>",
|
|
|
|
|
|
|
|
variant="danger",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Administrator note",
|
|
|
|
|
|
|
|
"<div style=\"white-space:pre-line;\">{{message}}</div>",
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What this means",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Your access has been removed or restricted across the linked services.",
|
|
|
|
|
|
|
|
"If you believe this is incorrect, contact the site administrator directly.",
|
|
|
|
|
|
|
|
"Do not rely on old links or cached sessions after this change.",
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
),
|
|
|
|
),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -227,6 +508,218 @@ def _build_default_base_url() -> str:
|
|
|
|
return f"http://localhost:{port}"
|
|
|
|
return f"http://localhost:{port}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_full_html_document(value: str) -> bool:
|
|
|
|
|
|
|
|
probe = value.lstrip().lower()
|
|
|
|
|
|
|
|
return probe.startswith("<!doctype") or probe.startswith("<html") or "<body" in probe[:300]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_action_button(label: str, url: str, *, primary: bool) -> str:
|
|
|
|
|
|
|
|
background = "linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%)" if primary else "#ffffff"
|
|
|
|
|
|
|
|
fallback = "#1c6bff" if primary else "#ffffff"
|
|
|
|
|
|
|
|
border = "1px solid rgba(28, 107, 255, 0.28)" if primary else "1px solid #d5deed"
|
|
|
|
|
|
|
|
color = "#ffffff" if primary else "#132033"
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
f"<a href=\"{html.escape(url)}\" "
|
|
|
|
|
|
|
|
f"style=\"display:inline-block; padding:12px 20px; margin:0 12px 12px 0; border-radius:999px; "
|
|
|
|
|
|
|
|
f"background-color:{fallback}; background:{background}; border:{border}; color:{color}; text-decoration:none; font-size:14px; "
|
|
|
|
|
|
|
|
f"font-weight:800; letter-spacing:0.01em;\">{html.escape(label)}</a>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
|
|
|
|
|
|
def _get_email_logo_bytes() -> bytes:
|
|
|
|
|
|
|
|
logo_path = Path(__file__).resolve().parents[1] / "assets" / "branding" / "logo.png"
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
return logo_path.read_bytes()
|
|
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
|
|
return b""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_email_logo_block(app_name: str) -> str:
|
|
|
|
|
|
|
|
if _get_email_logo_bytes():
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
f"<img src=\"cid:{EMAIL_LOGO_CID}\" alt=\"{html.escape(app_name)}\" width=\"52\" height=\"52\" "
|
|
|
|
|
|
|
|
"style=\"display:block; width:52px; height:52px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); "
|
|
|
|
|
|
|
|
"background:#0f1522; padding:6px;\" />"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<div style=\"width:52px; height:52px; border-radius:14px; border:1px solid rgba(255,255,255,0.08); "
|
|
|
|
|
|
|
|
"background:linear-gradient(135deg, #ff6b2b 0%, #1c6bff 100%); color:#ffffff; font-size:24px; "
|
|
|
|
|
|
|
|
"font-weight:900; text-align:center; line-height:52px;\">M</div>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_outlook_safe_test_email_html(
|
|
|
|
|
|
|
|
*,
|
|
|
|
|
|
|
|
app_name: str,
|
|
|
|
|
|
|
|
application_url: str,
|
|
|
|
|
|
|
|
build_number: str,
|
|
|
|
|
|
|
|
smtp_target: str,
|
|
|
|
|
|
|
|
security_mode: str,
|
|
|
|
|
|
|
|
auth_mode: str,
|
|
|
|
|
|
|
|
warning: str,
|
|
|
|
|
|
|
|
primary_url: str = "",
|
|
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
|
|
action_html = (
|
|
|
|
|
|
|
|
_build_email_action_button("Open Magent", primary_url, primary=True) if primary_url else ""
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
logo_block = _build_email_logo_block(app_name)
|
|
|
|
|
|
|
|
warning_block = (
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td style=\"padding:0 32px 24px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"border-collapse:separate; background:#fff5ea; border:1px solid #ffd5a8; border-radius:14px;\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:16px; color:#132033; font-size:14px; line-height:1.7;\">"
|
|
|
|
|
|
|
|
"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#c46a10; margin-bottom:8px;\">"
|
|
|
|
|
|
|
|
"Delivery notes</div>"
|
|
|
|
|
|
|
|
f"{html.escape(warning)}"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
) if warning else ""
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<!doctype html>"
|
|
|
|
|
|
|
|
"<html>"
|
|
|
|
|
|
|
|
"<body style=\"margin:0; padding:0; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"border-collapse:collapse; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
|
|
|
|
|
|
|
|
"<tr><td align=\"center\" style=\"padding:32px 16px;\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"680\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"width:680px; max-width:680px; border-collapse:collapse; background:#ffffff; border:1px solid #d5deed;\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:24px 32px; background:#0f172a;\" bgcolor=\"#0f172a\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
f"<td width=\"56\" valign=\"middle\">{logo_block}</td>"
|
|
|
|
|
|
|
|
"<td valign=\"middle\" style=\"padding-left:16px; font-family:Segoe UI, Arial, sans-serif; color:#ffffff;\">"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:28px; line-height:1.1; font-weight:800; color:#ffffff;\">{html.escape(app_name)} email test</div>"
|
|
|
|
|
|
|
|
"<div style=\"margin-top:6px; font-size:15px; line-height:1.5; color:#d5deed;\">This confirms Magent can generate and hand off branded mail.</div>"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
"</td></tr>"
|
|
|
|
|
|
|
|
"<tr><td height=\"6\" style=\"background:#ff6b2b; font-size:0; line-height:0;\"> </td></tr>"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:28px 32px 18px 32px; font-family:Segoe UI, Arial, sans-serif; color:#132033;\">"
|
|
|
|
|
|
|
|
"<div style=\"font-size:18px; line-height:1.6; color:#132033;\">This is a test email from <strong>Magent</strong>.</div>"
|
|
|
|
|
|
|
|
"</td></tr>"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td style=\"padding:0 32px 18px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td width=\"50%\" valign=\"top\" style=\"padding:0 8px 12px 0;\">"
|
|
|
|
|
|
|
|
f"{_build_email_stat_card('Build', build_number)}"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"<td width=\"50%\" valign=\"top\" style=\"padding:0 0 12px 8px;\">"
|
|
|
|
|
|
|
|
f"{_build_email_stat_card('Application URL', application_url)}"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td width=\"50%\" valign=\"top\" style=\"padding:0 8px 12px 0;\">"
|
|
|
|
|
|
|
|
f"{_build_email_stat_card('SMTP target', smtp_target)}"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"<td width=\"50%\" valign=\"top\" style=\"padding:0 0 12px 8px;\">"
|
|
|
|
|
|
|
|
f"{_build_email_stat_card('Security', security_mode, auth_mode)}"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td style=\"padding:0 32px 24px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"border-collapse:separate; background:#eef4ff; border:1px solid #bfd2ff; border-radius:14px;\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:16px; color:#132033; font-size:14px; line-height:1.8;\">"
|
|
|
|
|
|
|
|
"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#2754b6; margin-bottom:8px;\">"
|
|
|
|
|
|
|
|
"What this verifies</div>"
|
|
|
|
|
|
|
|
"<div>Magent can build the HTML template shell correctly.</div>"
|
|
|
|
|
|
|
|
"<div>The configured SMTP route accepts and relays the message.</div>"
|
|
|
|
|
|
|
|
"<div>Branding, links, and build metadata are rendering consistently.</div>"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
f"{warning_block}"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
"<td style=\"padding:0 32px 32px 32px; font-family:Segoe UI, Arial, sans-serif;\">"
|
|
|
|
|
|
|
|
f"{action_html}"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
"</body>"
|
|
|
|
|
|
|
|
"</html>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wrap_email_html(
|
|
|
|
|
|
|
|
*,
|
|
|
|
|
|
|
|
app_name: str,
|
|
|
|
|
|
|
|
app_url: str,
|
|
|
|
|
|
|
|
build_number: str,
|
|
|
|
|
|
|
|
title: str,
|
|
|
|
|
|
|
|
subtitle: str,
|
|
|
|
|
|
|
|
tone: str,
|
|
|
|
|
|
|
|
body_html: str,
|
|
|
|
|
|
|
|
primary_label: str = "",
|
|
|
|
|
|
|
|
primary_url: str = "",
|
|
|
|
|
|
|
|
secondary_label: str = "",
|
|
|
|
|
|
|
|
secondary_url: str = "",
|
|
|
|
|
|
|
|
footer_note: str = "",
|
|
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
|
|
styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
|
|
|
|
|
|
|
|
actions = []
|
|
|
|
|
|
|
|
if primary_label and primary_url:
|
|
|
|
|
|
|
|
actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
|
|
|
|
|
|
|
|
if secondary_label and secondary_url:
|
|
|
|
|
|
|
|
actions.append(_build_email_action_button(secondary_label, secondary_url, primary=False))
|
|
|
|
|
|
|
|
actions_html = "".join(actions)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
footer = footer_note or "This email was generated automatically by Magent."
|
|
|
|
|
|
|
|
logo_block = _build_email_logo_block(app_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
"<!doctype html>"
|
|
|
|
|
|
|
|
"<html><body style=\"margin:0; padding:0; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
|
|
|
|
|
|
|
|
"<div style=\"display:none; max-height:0; overflow:hidden; opacity:0;\">"
|
|
|
|
|
|
|
|
f"{html.escape(title)} - {html.escape(subtitle)}"
|
|
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"width:100%; border-collapse:collapse; background:#eef2f7;\" bgcolor=\"#eef2f7\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:32px 18px;\" bgcolor=\"#eef2f7\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
|
|
|
|
|
|
|
"style=\"max-width:680px; margin:0 auto; border-collapse:collapse;\">"
|
|
|
|
|
|
|
|
"<tr><td style=\"padding:0 0 18px;\">"
|
|
|
|
|
|
|
|
f"<div style=\"padding:24px 28px; background:#ffffff; border:1px solid #d5deed; border-radius:28px; box-shadow:0 18px 48px rgba(15,23,42,0.08);\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
f"<td style=\"vertical-align:middle; width:64px; padding:0 18px 0 0;\">{logo_block}</td>"
|
|
|
|
|
|
|
|
"<td style=\"vertical-align:middle;\">"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:11px; letter-spacing:0.18em; text-transform:uppercase; color:#6b778c; margin-bottom:6px;\">{html.escape(app_name)}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:30px; line-height:1.1; font-weight:900; color:#132033; margin:0 0 6px;\">{html.escape(title)}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"font-size:15px; line-height:1.6; color:#5c687d;\">{html.escape(subtitle or EMAIL_TAGLINE)}</div>"
|
|
|
|
|
|
|
|
"</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
f"<div style=\"height:6px; margin:22px 0 22px; border-radius:999px; background-color:{styles['accent_b']}; background:linear-gradient(90deg, {styles['accent_a']} 0%, {styles['accent_b']} 100%);\"></div>"
|
|
|
|
|
|
|
|
f"<div style=\"display:inline-block; padding:7px 12px; margin:0 0 16px; background:{styles['chip_bg']}; "
|
|
|
|
|
|
|
|
f"border:1px solid {styles['chip_border']}; border-radius:999px; color:{styles['chip_text']}; "
|
|
|
|
|
|
|
|
"font-size:11px; font-weight:800; letter-spacing:0.14em; text-transform:uppercase;\">"
|
|
|
|
|
|
|
|
f"{html.escape(EMAIL_TAGLINE)}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"color:#132033;\">{body_html}</div>"
|
|
|
|
|
|
|
|
f"<div style=\"margin:24px 0 0;\">{actions_html}</div>"
|
|
|
|
|
|
|
|
"<div style=\"margin:28px 0 0; padding:18px 0 0; border-top:1px solid #e2e8f0;\">"
|
|
|
|
|
|
|
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" style=\"border-collapse:collapse;\">"
|
|
|
|
|
|
|
|
"<tr>"
|
|
|
|
|
|
|
|
f"<td style=\"font-size:12px; line-height:1.7; color:#6b778c;\">{html.escape(footer)}</td>"
|
|
|
|
|
|
|
|
f"<td style=\"font-size:12px; line-height:1.7; color:#6b778c; text-align:right;\">Build {html.escape(build_number)}</td>"
|
|
|
|
|
|
|
|
"</tr>"
|
|
|
|
|
|
|
|
"</table>"
|
|
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
"</td></tr></table>"
|
|
|
|
|
|
|
|
"</body></html>"
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_invite_email_context(
|
|
|
|
def build_invite_email_context(
|
|
|
|
*,
|
|
|
|
*,
|
|
|
|
invite: Optional[Dict[str, Any]] = None,
|
|
|
|
invite: Optional[Dict[str, Any]] = None,
|
|
|
|
@@ -263,7 +756,7 @@ def build_invite_email_context(
|
|
|
|
invite.get("created_by") if invite else (user.get("username") if user else None),
|
|
|
|
invite.get("created_by") if invite else (user.get("username") if user else None),
|
|
|
|
"Admin",
|
|
|
|
"Admin",
|
|
|
|
),
|
|
|
|
),
|
|
|
|
"message": _normalize_display_text(message, ""),
|
|
|
|
"message": _normalize_display_text(message, "No additional note."),
|
|
|
|
"reason": _normalize_display_text(reason, "Not specified"),
|
|
|
|
"reason": _normalize_display_text(reason, "Not specified"),
|
|
|
|
"recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
|
|
|
|
"recipient_email": _normalize_display_text(resolved_recipient, "No email supplied"),
|
|
|
|
"role": _normalize_display_text(user.get("role") if user else None, "user"),
|
|
|
|
"role": _normalize_display_text(user.get("role") if user else None, "user"),
|
|
|
|
@@ -348,11 +841,35 @@ def render_invite_email_template(
|
|
|
|
reason=reason,
|
|
|
|
reason=reason,
|
|
|
|
overrides=overrides,
|
|
|
|
overrides=overrides,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
body_html = _render_template_string(template["body_html"], context, escape_html=True)
|
|
|
|
raw_body_html = _render_template_string(template["body_html"], context, escape_html=True)
|
|
|
|
body_text = _render_template_string(template["body_text"], context, escape_html=False)
|
|
|
|
body_text = _render_template_string(template["body_text"], context, escape_html=False)
|
|
|
|
if not body_text.strip() and body_html.strip():
|
|
|
|
if not body_text.strip() and raw_body_html.strip():
|
|
|
|
body_text = _strip_html_for_text(body_html)
|
|
|
|
body_text = _strip_html_for_text(raw_body_html)
|
|
|
|
subject = _render_template_string(template["subject"], context, escape_html=False)
|
|
|
|
subject = _render_template_string(template["subject"], context, escape_html=False)
|
|
|
|
|
|
|
|
presentation = TEMPLATE_PRESENTATION.get(template_key, TEMPLATE_PRESENTATION["invited"])
|
|
|
|
|
|
|
|
primary_url = _normalize_display_text(context.get(presentation["primary_url_key"], ""))
|
|
|
|
|
|
|
|
secondary_url = _normalize_display_text(context.get(presentation["secondary_url_key"], ""))
|
|
|
|
|
|
|
|
if _looks_like_full_html_document(raw_body_html):
|
|
|
|
|
|
|
|
body_html = raw_body_html.strip()
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
body_html = _wrap_email_html(
|
|
|
|
|
|
|
|
app_name=_normalize_display_text(context.get("app_name"), env_settings.app_name),
|
|
|
|
|
|
|
|
app_url=_normalize_display_text(context.get("app_url"), _build_default_base_url()),
|
|
|
|
|
|
|
|
build_number=_normalize_display_text(context.get("build_number"), BUILD_NUMBER),
|
|
|
|
|
|
|
|
title=_normalize_display_text(context.get("title"), presentation["title"]),
|
|
|
|
|
|
|
|
subtitle=_normalize_display_text(context.get("subtitle"), presentation["subtitle"]),
|
|
|
|
|
|
|
|
tone=_normalize_display_text(context.get("tone"), presentation["tone"]),
|
|
|
|
|
|
|
|
body_html=raw_body_html.strip(),
|
|
|
|
|
|
|
|
primary_label=_normalize_display_text(
|
|
|
|
|
|
|
|
context.get("primary_label"), presentation["primary_label"]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
primary_url=primary_url,
|
|
|
|
|
|
|
|
secondary_label=_normalize_display_text(
|
|
|
|
|
|
|
|
context.get("secondary_label"), presentation["secondary_label"]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
secondary_url=secondary_url,
|
|
|
|
|
|
|
|
footer_note=_normalize_display_text(context.get("footer_note"), ""),
|
|
|
|
|
|
|
|
).strip()
|
|
|
|
return {
|
|
|
|
return {
|
|
|
|
"subject": subject.strip(),
|
|
|
|
"subject": subject.strip(),
|
|
|
|
"body_text": body_text.strip(),
|
|
|
|
"body_text": body_text.strip(),
|
|
|
|
@@ -402,7 +919,7 @@ def smtp_email_delivery_warning() -> Optional[str]:
|
|
|
|
|
|
|
|
|
|
|
|
def _flatten_message(message: EmailMessage) -> bytes:
|
|
|
|
def _flatten_message(message: EmailMessage) -> bytes:
|
|
|
|
buffer = BytesIO()
|
|
|
|
buffer = BytesIO()
|
|
|
|
BytesGenerator(buffer).flatten(message)
|
|
|
|
BytesGenerator(buffer, policy=SMTP_POLICY).flatten(message)
|
|
|
|
return buffer.getvalue()
|
|
|
|
return buffer.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -480,9 +997,27 @@ def _send_email_sync(*, recipient_email: str, subject: str, body_text: str, body
|
|
|
|
message["Subject"] = subject
|
|
|
|
message["Subject"] = subject
|
|
|
|
message["From"] = formataddr((from_name, from_address))
|
|
|
|
message["From"] = formataddr((from_name, from_address))
|
|
|
|
message["To"] = recipient_email
|
|
|
|
message["To"] = recipient_email
|
|
|
|
|
|
|
|
message["Date"] = formatdate(localtime=True)
|
|
|
|
|
|
|
|
if "@" in from_address:
|
|
|
|
|
|
|
|
message["Message-ID"] = make_msgid(domain=from_address.split("@", 1)[1])
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
message["Message-ID"] = make_msgid()
|
|
|
|
message.set_content(body_text or _strip_html_for_text(body_html))
|
|
|
|
message.set_content(body_text or _strip_html_for_text(body_html))
|
|
|
|
if body_html.strip():
|
|
|
|
if body_html.strip():
|
|
|
|
message.add_alternative(body_html, subtype="html")
|
|
|
|
message.add_alternative(body_html, subtype="html")
|
|
|
|
|
|
|
|
if f"cid:{EMAIL_LOGO_CID}" in body_html:
|
|
|
|
|
|
|
|
logo_bytes = _get_email_logo_bytes()
|
|
|
|
|
|
|
|
if logo_bytes:
|
|
|
|
|
|
|
|
html_part = message.get_body(preferencelist=("html",))
|
|
|
|
|
|
|
|
if html_part is not None:
|
|
|
|
|
|
|
|
html_part.add_related(
|
|
|
|
|
|
|
|
logo_bytes,
|
|
|
|
|
|
|
|
maintype="image",
|
|
|
|
|
|
|
|
subtype="png",
|
|
|
|
|
|
|
|
cid=f"<{EMAIL_LOGO_CID}>",
|
|
|
|
|
|
|
|
filename="logo.png",
|
|
|
|
|
|
|
|
disposition="inline",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if use_ssl:
|
|
|
|
if use_ssl:
|
|
|
|
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
|
|
|
|
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
|
|
|
|
@@ -592,17 +1127,71 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
|
|
|
|
raise RuntimeError("No valid recipient email is configured for the test message.")
|
|
|
|
raise RuntimeError("No valid recipient email is configured for the test message.")
|
|
|
|
|
|
|
|
|
|
|
|
application_url = _normalize_display_text(runtime.magent_application_url, "Not configured")
|
|
|
|
application_url = _normalize_display_text(runtime.magent_application_url, "Not configured")
|
|
|
|
|
|
|
|
primary_url = application_url if application_url.lower().startswith(("http://", "https://")) else ""
|
|
|
|
|
|
|
|
smtp_target = f"{_normalize_display_text(runtime.magent_notify_email_smtp_host, 'Not configured')}:{int(runtime.magent_notify_email_smtp_port or 587)}"
|
|
|
|
|
|
|
|
security_mode = "SSL" if runtime.magent_notify_email_use_ssl else ("STARTTLS" if runtime.magent_notify_email_use_tls else "Plain SMTP")
|
|
|
|
|
|
|
|
auth_mode = "Authenticated" if (
|
|
|
|
|
|
|
|
_normalize_display_text(runtime.magent_notify_email_smtp_username)
|
|
|
|
|
|
|
|
and _normalize_display_text(runtime.magent_notify_email_smtp_password)
|
|
|
|
|
|
|
|
) else "No SMTP auth"
|
|
|
|
|
|
|
|
delivery_warning = smtp_email_delivery_warning()
|
|
|
|
subject = f"{env_settings.app_name} email test"
|
|
|
|
subject = f"{env_settings.app_name} email test"
|
|
|
|
body_text = (
|
|
|
|
body_text = (
|
|
|
|
f"This is a test email from {env_settings.app_name}.\n\n"
|
|
|
|
f"This is a test email from {env_settings.app_name}.\n\n"
|
|
|
|
f"Build: {BUILD_NUMBER}\n"
|
|
|
|
f"Build: {BUILD_NUMBER}\n"
|
|
|
|
f"Application URL: {application_url}\n"
|
|
|
|
f"Application URL: {application_url}\n"
|
|
|
|
|
|
|
|
f"SMTP target: {smtp_target}\n"
|
|
|
|
|
|
|
|
f"Security: {security_mode} ({auth_mode})\n\n"
|
|
|
|
|
|
|
|
"What this verifies:\n"
|
|
|
|
|
|
|
|
"- Magent can build the HTML template shell correctly.\n"
|
|
|
|
|
|
|
|
"- The configured SMTP route accepts and relays the message.\n"
|
|
|
|
|
|
|
|
"- Branding, links, and build metadata are rendering consistently.\n"
|
|
|
|
)
|
|
|
|
)
|
|
|
|
body_html = (
|
|
|
|
body_html = _wrap_email_html(
|
|
|
|
f"<h1>{html.escape(env_settings.app_name)} email test</h1>"
|
|
|
|
app_name=env_settings.app_name,
|
|
|
|
f"<p>This is a test email from <strong>{html.escape(env_settings.app_name)}</strong>.</p>"
|
|
|
|
app_url=_build_default_base_url(),
|
|
|
|
f"<p><strong>Build:</strong> {html.escape(BUILD_NUMBER)}<br />"
|
|
|
|
build_number=BUILD_NUMBER,
|
|
|
|
f"<strong>Application URL:</strong> {html.escape(application_url)}</p>"
|
|
|
|
title="Email delivery test",
|
|
|
|
|
|
|
|
subtitle="This confirms Magent can generate and hand off branded mail.",
|
|
|
|
|
|
|
|
tone="brand",
|
|
|
|
|
|
|
|
body_html=(
|
|
|
|
|
|
|
|
"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
|
|
|
|
"This is a live test email from Magent. If this renders correctly, the HTML template shell and SMTP handoff are both working."
|
|
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
_build_email_stat_card("Recipient", resolved_email),
|
|
|
|
|
|
|
|
_build_email_stat_card("Build", BUILD_NUMBER),
|
|
|
|
|
|
|
|
_build_email_stat_card("SMTP target", smtp_target),
|
|
|
|
|
|
|
|
_build_email_stat_card("Security", security_mode, auth_mode),
|
|
|
|
|
|
|
|
_build_email_stat_card("Application URL", application_url),
|
|
|
|
|
|
|
|
_build_email_stat_card("Template shell", "Branded HTML", "Logo, gradient, action buttons"),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What this verifies",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Magent can build the HTML template shell correctly.",
|
|
|
|
|
|
|
|
"The configured SMTP route accepts and relays the message.",
|
|
|
|
|
|
|
|
"Branding, links, and build metadata are rendering consistently.",
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="brand",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Delivery notes",
|
|
|
|
|
|
|
|
(
|
|
|
|
|
|
|
|
f"<div style=\"white-space:pre-line;\">{html.escape(delivery_warning)}</div>"
|
|
|
|
|
|
|
|
if delivery_warning
|
|
|
|
|
|
|
|
else "Use this test when changing SMTP settings, relay targets, or branding."
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="warning" if delivery_warning else "neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
primary_label="Open Magent" if primary_url else "",
|
|
|
|
|
|
|
|
primary_url=primary_url,
|
|
|
|
|
|
|
|
footer_note="SMTP test email generated by Magent.",
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
receipt = await asyncio.to_thread(
|
|
|
|
receipt = await asyncio.to_thread(
|
|
|
|
@@ -654,13 +1243,53 @@ async def send_password_reset_email(
|
|
|
|
f"Expires: {expires_at}\n\n"
|
|
|
|
f"Expires: {expires_at}\n\n"
|
|
|
|
"If you did not request this reset, you can ignore this email.\n"
|
|
|
|
"If you did not request this reset, you can ignore this email.\n"
|
|
|
|
)
|
|
|
|
)
|
|
|
|
body_html = (
|
|
|
|
body_html = _wrap_email_html(
|
|
|
|
f"<h1>{html.escape(env_settings.app_name)} password reset</h1>"
|
|
|
|
app_name=env_settings.app_name,
|
|
|
|
f"<p>A password reset was requested for <strong>{html.escape(username)}</strong>.</p>"
|
|
|
|
app_url=app_url,
|
|
|
|
f"<p>This link will reset the password used for <strong>{html.escape(provider_label)}</strong>.</p>"
|
|
|
|
build_number=BUILD_NUMBER,
|
|
|
|
f"<p><a href=\"{html.escape(reset_url)}\">Reset password</a></p>"
|
|
|
|
title="Reset your password",
|
|
|
|
f"<p><strong>Expires:</strong> {html.escape(expires_at)}</p>"
|
|
|
|
subtitle=f"This will update the credentials used for {provider_label}.",
|
|
|
|
"<p>If you did not request this reset, you can ignore this email.</p>"
|
|
|
|
tone="brand",
|
|
|
|
|
|
|
|
body_html=(
|
|
|
|
|
|
|
|
f"<div style=\"margin:0 0 18px; color:#132033; font-size:15px; line-height:1.7;\">"
|
|
|
|
|
|
|
|
f"A password reset was requested for <strong>{html.escape(username)}</strong>."
|
|
|
|
|
|
|
|
"</div>"
|
|
|
|
|
|
|
|
+ _build_email_stat_grid(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
_build_email_stat_card("Account", username),
|
|
|
|
|
|
|
|
_build_email_stat_card("Expires", expires_at),
|
|
|
|
|
|
|
|
_build_email_stat_card("Credentials updated", provider_label),
|
|
|
|
|
|
|
|
_build_email_stat_card("Delivery target", resolved_email),
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What will be updated",
|
|
|
|
|
|
|
|
f"This reset will update the password used for <strong>{html.escape(provider_label)}</strong>.",
|
|
|
|
|
|
|
|
variant="brand",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"What happens next",
|
|
|
|
|
|
|
|
_build_email_list(
|
|
|
|
|
|
|
|
[
|
|
|
|
|
|
|
|
"Open the reset link and choose a new password.",
|
|
|
|
|
|
|
|
"Complete the form before the expiry time shown above.",
|
|
|
|
|
|
|
|
"Use the new password the next time you sign in.",
|
|
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
ordered=True,
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
variant="neutral",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
+ _build_email_panel(
|
|
|
|
|
|
|
|
"Safety note",
|
|
|
|
|
|
|
|
"If you did not request this reset, ignore this email. No changes will be applied until the reset link is opened and completed.",
|
|
|
|
|
|
|
|
variant="warning",
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
),
|
|
|
|
|
|
|
|
primary_label="Reset password",
|
|
|
|
|
|
|
|
primary_url=reset_url,
|
|
|
|
|
|
|
|
secondary_label="Open Magent",
|
|
|
|
|
|
|
|
secondary_url=app_url,
|
|
|
|
|
|
|
|
footer_note="Password reset email generated by Magent.",
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
receipt = await asyncio.to_thread(
|
|
|
|
receipt = await asyncio.to_thread(
|
|
|
|
|