import aiosmtplib from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from sqlalchemy import select from app.core.database import async_session from app.core.config import get_settings from app.models.models import SystemSetting settings = get_settings() async def get_site_url() -> str: async with async_session() as db: result = await db.execute(select(SystemSetting).where(SystemSetting.key == "site_url")) setting = result.scalar_one_or_none() if setting and setting.value.strip(): return setting.value.strip().rstrip("/") return "http://localhost:806" async def get_smtp_config() -> dict: async with async_session() as db: result = await db.execute( select(SystemSetting).where( SystemSetting.key.in_([ "smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_use_tls", ]) ) ) rows = {r.key: r.value for r in result.scalars().all()} if rows.get("smtp_host") and rows.get("smtp_user"): return { "host": rows.get("smtp_host", ""), "port": int(rows.get("smtp_port", "465")), "user": rows.get("smtp_user", ""), "password": rows.get("smtp_password", ""), "from_addr": rows.get("smtp_from") or rows.get("smtp_user", ""), "use_tls": rows.get("smtp_use_tls", "true") == "true", } return { "host": settings.SMTP_HOST, "port": settings.SMTP_PORT, "user": settings.SMTP_USER, "password": settings.SMTP_PASSWORD, "from_addr": settings.SMTP_FROM or settings.SMTP_USER, "use_tls": settings.SMTP_USE_TLS, } async def send_email(to_email: str, subject: str, body: str) -> bool: smtp = await get_smtp_config() if not smtp["host"] or not smtp["user"]: return False msg = MIMEMultipart("alternative") msg["From"] = smtp["from_addr"] msg["To"] = to_email msg["Subject"] = subject msg.attach(MIMEText(body, "html", "utf-8")) try: await aiosmtplib.send( msg, hostname=smtp["host"], port=smtp["port"], username=smtp["user"], password=smtp["password"], use_tls=smtp["use_tls"], ) return True except Exception: return False def build_repayment_reminder_html( nickname: str, creditor: str, amount_yuan: float, due_date: str, days_left: int, is_overdue: bool = False ) -> str: status_text = f"已逾期 {abs(days_left)} 天" if is_overdue else f"还有 {days_left} 天到期" color = "#ff3b30" if is_overdue else "#0071e3" title = "逾期提醒" if is_overdue else "还款提醒" return f"""

{title}

Hi {nickname},

您有一笔借款即将到期:

债权人:{creditor}

金额:¥{amount_yuan:,.2f}

到期日:{due_date}

{status_text}

—— 债务管理系统

""" _LOGIN_ALERT_CONFIG = { 3: { "level": "注意", "color": "#ff9500", "bg": "rgba(255,149,0,.06)", "border": "rgba(255,149,0,.15)", "icon": "⚠️", "desc": "您的账号出现了异常登录尝试。", }, 5: { "level": "警告", "color": "#ff6b35", "bg": "rgba(255,107,53,.06)", "border": "rgba(255,107,53,.15)", "icon": "🔶", "desc": "您的账号正遭受多次暴力破解尝试,若非本人操作请立即修改密码。", }, 10: { "level": "严重", "color": "#ff3b30", "bg": "rgba(255,59,48,.08)", "border": "rgba(255,59,48,.18)", "icon": "🔴", "desc": "您的账号已被暴力破解锁定 5 分钟。若非本人操作,请立即修改密码并检查账户安全。", }, } def _build_login_alert_html(nickname: str, count: int, threshold: int, ip: str, site_url: str, token: str, uid: int, ttl_minutes: int = 5) -> str: cfg = _LOGIN_ALERT_CONFIG[threshold] change_pwd_url = f"{site_url}/reset-password.html?token={token}&uid={uid}" return f"""

{cfg['icon']} 登录安全{cfg['level']}

Hi {nickname},

{cfg['desc']}

异常登录次数:{count} 次

触发来源 IP:{ip}

时间:{__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}

修改密码

此链接 {ttl_minutes} 分钟内有效,仅可使用一次。

如非本人操作,请忽略此邮件并尽快检查账户安全。

—— 债务管理系统

""" async def send_login_alert_email(user, count: int, threshold: int, ip: str): if not user.real_email: return import secrets from datetime import datetime, timedelta token = secrets.token_urlsafe(32) ttl_minutes = 5 expires_at = (datetime.utcnow() + timedelta(minutes=ttl_minutes)).isoformat() key = f"pwd_reset:{user.id}" value = f"{token}|{expires_at}" async with async_session() as db: result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) setting = result.scalar_one_or_none() if setting: setting.value = value else: db.add(SystemSetting(key=key, value=value)) await db.commit() cfg = _LOGIN_ALERT_CONFIG[threshold] site_url = await get_site_url() html = _build_login_alert_html(user.nickname or user.email, count, threshold, ip, site_url, token, user.id, ttl_minutes) subject = f"【{cfg['level']}】账号安全提醒 - 异常登录 {count} 次" await send_email(user.real_email, subject, html) def _build_password_changed_html(nickname: str, action: str, operator: str = "") -> str: from datetime import datetime now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return f"""

🔐 密码{action}通知

Hi {nickname},

您的账户密码已被{action}。

操作类型:密码{action}

操作时间:{now}

{"

操作人:" + operator + "

" if operator else ""}

如非本人操作,请立即联系管理员并修改密码。

—— 债务管理系统

""" async def send_password_changed_email(user, action: str, operator: str = ""): if not user.real_email: return html = _build_password_changed_html(user.nickname or user.email, action, operator) subject = f"【安全通知】您的密码已被{action}" await send_email(user.real_email, subject, html) def _build_password_reset_link_html(nickname: str, reset_url: str, ttl_hours: int, operator: str) -> str: from datetime import datetime now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return f"""

🔐 密码重置链接

Hi {nickname},

管理员已为您发起密码重置,请点击下方按钮设置新密码。

操作人:{operator}

发送时间:{now}

有效期:{ttl_hours} 小时内有效

重置密码

如非本人操作,请忽略此邮件。

—— 债务管理系统

""" async def send_password_reset_link_email(user, reset_url: str, ttl_hours: int, operator: str = ""): if not user.real_email: return html = _build_password_reset_link_html(user.nickname or user.email, reset_url, ttl_hours, operator) subject = "【密码重置】管理员为您重置了密码" await send_email(user.real_email, subject, html)