Fix email branding with inline logo and reliable MIME transport
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0303261719
|
0303261841
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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": {
|
||||||
@@ -145,14 +149,15 @@ def _build_email_stat_card(label: str, value: str, detail: str = "") -> str:
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
"<div style=\"padding:16px; background:#f8fafc; border:1px solid #d9e2ef; "
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
||||||
"border-radius:16px; color:#132033;\">"
|
"style=\"border-collapse:separate; background:#f8fafc; border:1px solid #d9e2ef; border-radius:16px;\">"
|
||||||
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:#6b778c; "
|
"<tr><td style=\"padding:16px; font-family:Segoe UI, Arial, sans-serif; color:#132033;\">"
|
||||||
f"margin-bottom:8px;\">{html.escape(label)}</div>"
|
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"<div style=\"font-size:20px; font-weight:800; line-height:1.45; word-break:break-word; color:#132033;\">"
|
||||||
f"{html.escape(value)}</div>"
|
f"{html.escape(value)}</div>"
|
||||||
f"{detail_html}"
|
f"{detail_html}"
|
||||||
"</div>"
|
"</td></tr></table>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -229,12 +234,14 @@ def _build_email_panel(title: str, body_html: str, *, variant: str = "neutral")
|
|||||||
"text": "#132033",
|
"text": "#132033",
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
f"<div style=\"margin:0 0 18px; padding:18px; background:{styles['background']}; "
|
"<table role=\"presentation\" width=\"100%\" cellspacing=\"0\" cellpadding=\"0\" "
|
||||||
f"border:1px solid {styles['border']}; border-radius:18px; color:{styles['text']};\">"
|
f"style=\"border-collapse:separate; margin:0 0 18px; background:{styles['background']}; "
|
||||||
f"<div style=\"font-size:11px; letter-spacing:0.12em; text-transform:uppercase; color:{styles['eyebrow']}; "
|
f"border:1px solid {styles['border']}; border-radius:18px;\">"
|
||||||
f"margin-bottom:10px;\">{html.escape(title)}</div>"
|
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>"
|
f"<div style=\"font-size:14px; line-height:1.8; color:{styles['text']};\">{body_html}</div>"
|
||||||
"</div>"
|
"</td></tr></table>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -519,6 +526,129 @@ def _build_email_action_button(label: str, url: str, *, primary: bool) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
def _wrap_email_html(
|
||||||
*,
|
*,
|
||||||
app_name: str,
|
app_name: str,
|
||||||
@@ -535,10 +665,6 @@ def _wrap_email_html(
|
|||||||
footer_note: str = "",
|
footer_note: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
|
styles = EMAIL_TONE_STYLES.get(tone, EMAIL_TONE_STYLES["brand"])
|
||||||
logo_url = ""
|
|
||||||
if app_url.lower().startswith("http://") or app_url.lower().startswith("https://"):
|
|
||||||
logo_url = f"{app_url.rstrip('/')}/branding/logo.png"
|
|
||||||
|
|
||||||
actions = []
|
actions = []
|
||||||
if primary_label and primary_url:
|
if primary_label and primary_url:
|
||||||
actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
|
actions.append(_build_email_action_button(primary_label, primary_url, primary=True))
|
||||||
@@ -547,17 +673,7 @@ def _wrap_email_html(
|
|||||||
actions_html = "".join(actions)
|
actions_html = "".join(actions)
|
||||||
|
|
||||||
footer = footer_note or "This email was generated automatically by Magent."
|
footer = footer_note or "This email was generated automatically by Magent."
|
||||||
logo_block = (
|
logo_block = _build_email_logo_block(app_name)
|
||||||
f"<img src=\"{html.escape(logo_url)}\" 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;\" />"
|
|
||||||
if logo_url
|
|
||||||
else (
|
|
||||||
"<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>"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"<!doctype html>"
|
"<!doctype html>"
|
||||||
@@ -803,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()
|
||||||
|
|
||||||
|
|
||||||
@@ -881,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:
|
||||||
@@ -1006,6 +1140,12 @@ async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, st
|
|||||||
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 = _wrap_email_html(
|
body_html = _wrap_email_html(
|
||||||
app_name=env_settings.app_name,
|
app_name=env_settings.app_name,
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0303261719",
|
"version": "0303261841",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0303261719",
|
"version": "0303261841",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0303261719",
|
"version": "0303261841",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user