223 lines
7.3 KiB
Python
223 lines
7.3 KiB
Python
import logging
|
||
from email.mime.multipart import MIMEMultipart
|
||
from email.mime.text import MIMEText
|
||
|
||
import aiosmtplib
|
||
|
||
from app.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def build_email_shell(
|
||
*,
|
||
eyebrow: str,
|
||
title: str,
|
||
body_html: str,
|
||
action_label: str | None = None,
|
||
action_url: str | None = None,
|
||
summary_html: str | None = None,
|
||
footer_note: str = "此邮件由系统自动发送,请勿直接回复。",
|
||
) -> str:
|
||
action_html = ""
|
||
if action_label and action_url:
|
||
action_html = f"""
|
||
<div style="margin-top:28px;">
|
||
<a
|
||
href="{action_url}"
|
||
style="
|
||
display:inline-block;
|
||
padding:12px 22px;
|
||
border-radius:999px;
|
||
background:linear-gradient(135deg,#6c1a25 0%,#915435 62%,#d9b68d 130%);
|
||
color:#fffaf3;
|
||
text-decoration:none;
|
||
font-size:14px;
|
||
font-weight:600;
|
||
letter-spacing:0.01em;
|
||
box-shadow:0 14px 32px -18px rgba(83,25,24,0.55);
|
||
"
|
||
>
|
||
{action_label}
|
||
</a>
|
||
</div>
|
||
"""
|
||
|
||
summary_block = ""
|
||
if summary_html:
|
||
summary_block = f"""
|
||
<div
|
||
style="
|
||
margin:18px 0 0;
|
||
padding:14px 16px;
|
||
border:1px solid #eadcc8;
|
||
border-radius:18px;
|
||
background:#fff4e3;
|
||
font-size:13px;
|
||
line-height:1.8;
|
||
color:#84553c;
|
||
"
|
||
>
|
||
{summary_html}
|
||
</div>
|
||
"""
|
||
|
||
return f"""
|
||
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<body style="margin:0;padding:0;background:#f7efe2;">
|
||
<div style="margin:0;padding:32px 16px;background:linear-gradient(180deg,#f7efe2 0%,#f4ebdf 38%,#f9f5ee 100%);">
|
||
<div style="max-width:640px;margin:0 auto;">
|
||
<div
|
||
style="
|
||
overflow:hidden;
|
||
border:1px solid #e7d3ba;
|
||
border-radius:28px;
|
||
background:#fffaf3;
|
||
box-shadow:0 28px 80px -44px rgba(83,25,24,0.34);
|
||
"
|
||
>
|
||
<div
|
||
style="
|
||
padding:32px 36px 28px;
|
||
background:
|
||
radial-gradient(circle at top, rgba(115,25,37,0.18), transparent 58%),
|
||
linear-gradient(135deg, rgba(108,26,37,0.98), rgba(145,84,53,0.92) 58%, rgba(233,206,160,0.88) 140%);
|
||
color:#fff7ee;
|
||
"
|
||
>
|
||
<div
|
||
style="
|
||
display:inline-block;
|
||
padding:7px 12px;
|
||
border:1px solid rgba(255,245,231,0.28);
|
||
border-radius:999px;
|
||
background:rgba(255,250,242,0.08);
|
||
font-size:11px;
|
||
letter-spacing:0.24em;
|
||
text-transform:uppercase;
|
||
"
|
||
>
|
||
{eyebrow}
|
||
</div>
|
||
<h1 style="margin:18px 0 0;font-size:30px;line-height:1.22;font-weight:700;">
|
||
香港大学中国商业学院
|
||
</h1>
|
||
<p style="margin:8px 0 0;font-size:14px;line-height:1.7;color:rgba(255,247,238,0.86);">
|
||
HKU ICB · 班级信息管理平台
|
||
</p>
|
||
</div>
|
||
|
||
<div style="padding:32px 36px 14px;">
|
||
<h2 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#4e1d1a;">
|
||
{title}
|
||
</h2>
|
||
<div style="font-size:15px;line-height:1.9;color:#775a4a;">
|
||
{body_html}
|
||
</div>
|
||
{summary_block}
|
||
{action_html}
|
||
</div>
|
||
|
||
<div style="padding:18px 36px 30px;">
|
||
<div
|
||
style="
|
||
border-top:1px solid #eadcc8;
|
||
padding-top:16px;
|
||
font-size:12px;
|
||
line-height:1.8;
|
||
color:#9a7b68;
|
||
"
|
||
>
|
||
{footer_note}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
async def send_email(to: str, subject: str, html_body: str) -> bool:
|
||
"""Send HTML email via SMTP. Returns True on success."""
|
||
if not settings.smtp_host:
|
||
logger.info(f"SMTP not configured, skipping email to {to}: {subject}")
|
||
return False
|
||
|
||
msg = MIMEMultipart("alternative")
|
||
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
|
||
msg["To"] = to
|
||
msg["Subject"] = subject
|
||
msg.attach(MIMEText(html_body, "html"))
|
||
|
||
try:
|
||
await aiosmtplib.send(
|
||
msg,
|
||
hostname=settings.smtp_host,
|
||
port=settings.smtp_port,
|
||
username=settings.smtp_user,
|
||
password=settings.smtp_password,
|
||
use_tls=True,
|
||
)
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Failed to send email to {to}: {e}")
|
||
return False
|
||
|
||
|
||
async def send_account_activated_email(member_email: str) -> bool:
|
||
login_url = f"{settings.frontend_url.rstrip('/')}/login"
|
||
html = build_email_shell(
|
||
eyebrow="Account Ready",
|
||
title="账号已激活,可以开始使用",
|
||
body_html="""
|
||
<p style="margin:0 0 12px;">你的 HKU ICB 班级账号已经成功激活。</p>
|
||
<p style="margin:0;">现在可以使用注册邮箱登录平台,进入班级空间查看公告、排期、资源与成员信息。</p>
|
||
""",
|
||
action_label="立即登录",
|
||
action_url=login_url,
|
||
)
|
||
return await send_email(member_email, "HKU ICB:账号激活成功", html)
|
||
|
||
|
||
async def send_email_verification_code_email(member_email: str, code: str) -> bool:
|
||
html = build_email_shell(
|
||
eyebrow="Email Verification",
|
||
title="请验证你的邮箱地址",
|
||
body_html="""
|
||
<p style="margin:0 0 12px;">你正在激活 HKU ICB 班级账号,请使用下方验证码完成邮箱验证。</p>
|
||
<p style="margin:0;">验证码 10 分钟内有效;如果不是你本人操作,可以忽略此邮件。</p>
|
||
""",
|
||
summary_html=f"""
|
||
<div style="font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#9a7b68;">邮箱验证码</div>
|
||
<div style="margin-top:8px;font-size:32px;line-height:1.2;font-weight:700;letter-spacing:0.32em;color:#4e1d1a;">{code}</div>
|
||
""",
|
||
)
|
||
return await send_email(member_email, "HKU ICB:邮箱验证码", html)
|
||
|
||
|
||
async def send_class_notification_email(
|
||
emails: list[str],
|
||
subject: str,
|
||
title: str,
|
||
body: str,
|
||
action_url: str | None = None,
|
||
eyebrow: str = "Class Update",
|
||
action_label: str | None = "查看详情",
|
||
summary_html: str | None = None,
|
||
):
|
||
"""Send a styled notification email to class members."""
|
||
normalized_body = body.replace("\n", "<br />")
|
||
html = build_email_shell(
|
||
eyebrow=eyebrow,
|
||
title=title,
|
||
body_html=f"<div>{normalized_body}</div>",
|
||
action_label=action_label if action_url else None,
|
||
action_url=action_url,
|
||
summary_html=summary_html,
|
||
)
|
||
for email in emails:
|
||
await send_email(email, subject, html)
|