企業內部新聞彙整與分析系統 - 自動新聞抓取 (Digitimes, 經濟日報, 工商時報) - AI 智慧摘要 (OpenAI/Claude/Ollama) - 群組管理與訂閱通知 - 已清理 Python 快取檔案 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
204 lines
6.3 KiB
Python
204 lines
6.3 KiB
Python
"""
|
|
通知服務模組
|
|
處理 Email 發送
|
|
"""
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from typing import Optional
|
|
from html import escape
|
|
from sqlalchemy.orm import Session
|
|
import logging
|
|
|
|
from app.core.config import settings
|
|
from app.models import Report, Subscription, User, NotificationLog, NotificationStatus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def send_email(to_email: str, subject: str, html_content: str) -> bool:
|
|
"""
|
|
發送 Email
|
|
|
|
Returns:
|
|
是否發送成功
|
|
"""
|
|
if not settings.smtp_host:
|
|
logger.warning("SMTP 未設定,跳過發送")
|
|
return False
|
|
|
|
try:
|
|
msg = MIMEMultipart("alternative")
|
|
msg["Subject"] = subject
|
|
msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_from_email}>"
|
|
msg["To"] = to_email
|
|
|
|
html_part = MIMEText(html_content, "html", "utf-8")
|
|
msg.attach(html_part)
|
|
|
|
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
|
|
server.starttls()
|
|
if settings.smtp_username and settings.smtp_password:
|
|
server.login(settings.smtp_username, settings.smtp_password)
|
|
server.sendmail(settings.smtp_from_email, to_email, msg.as_string())
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error("Email 發送失敗", exc_info=True)
|
|
return False
|
|
|
|
|
|
def create_report_email_content(report: Report, base_url: str = "") -> str:
|
|
"""建立報告通知 Email 內容"""
|
|
summary = report.edited_summary or report.ai_summary or "無摘要內容"
|
|
|
|
# 截取摘要前 500 字
|
|
if len(summary) > 500:
|
|
summary = summary[:500] + "..."
|
|
|
|
# 轉義 HTML 特殊字元,防止 XSS
|
|
safe_title = escape(report.title)
|
|
safe_group_name = escape(report.group.name)
|
|
safe_summary = escape(summary)
|
|
safe_base_url = escape(base_url)
|
|
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<style>
|
|
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
|
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
|
.header {{ background: #4a6fa5; color: white; padding: 20px; text-align: center; }}
|
|
.content {{ padding: 20px; background: #f9f9f9; }}
|
|
.summary {{ background: white; padding: 15px; border-left: 4px solid #4a6fa5; margin: 15px 0; }}
|
|
.button {{ display: inline-block; padding: 12px 24px; background: #4a6fa5; color: white; text-decoration: none; border-radius: 4px; }}
|
|
.footer {{ text-align: center; padding: 20px; color: #666; font-size: 12px; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1 style="margin:0;">每日報導</h1>
|
|
</div>
|
|
<div class="content">
|
|
<h2>{safe_title}</h2>
|
|
<p>
|
|
<strong>群組:</strong>{safe_group_name}<br>
|
|
<strong>日期:</strong>{report.report_date}
|
|
</p>
|
|
<div class="summary">
|
|
<h3>摘要</h3>
|
|
<p>{safe_summary}</p>
|
|
</div>
|
|
<p style="text-align: center; margin-top: 30px;">
|
|
<a href="{safe_base_url}/reports/{report.id}" class="button">閱讀完整報告</a>
|
|
</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p>此郵件由每日報導系統自動發送</p>
|
|
<p>如不想收到通知,請至系統調整訂閱設定</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
|
|
def send_report_notifications(db: Session, report: Report) -> int:
|
|
"""
|
|
發送報告通知給訂閱者
|
|
|
|
Returns:
|
|
發送成功數量
|
|
"""
|
|
# 取得訂閱此群組的用戶
|
|
subscriptions = db.query(Subscription).filter(
|
|
Subscription.group_id == report.group_id,
|
|
Subscription.email_notify == True
|
|
).all()
|
|
|
|
sent_count = 0
|
|
|
|
for sub in subscriptions:
|
|
user = db.query(User).filter(User.id == sub.user_id).first()
|
|
if not user or not user.email or not user.is_active:
|
|
continue
|
|
|
|
# 建立通知記錄
|
|
notification = NotificationLog(
|
|
user_id=user.id,
|
|
report_id=report.id,
|
|
notification_type="email",
|
|
subject=f"【每日報導】{report.title}",
|
|
content=report.edited_summary or report.ai_summary
|
|
)
|
|
db.add(notification)
|
|
|
|
# 發送 Email
|
|
html_content = create_report_email_content(report)
|
|
success = send_email(
|
|
user.email,
|
|
f"【每日報導】{report.title}",
|
|
html_content
|
|
)
|
|
|
|
if success:
|
|
notification.status = NotificationStatus.SENT
|
|
from datetime import datetime
|
|
notification.sent_at = datetime.utcnow()
|
|
sent_count += 1
|
|
else:
|
|
notification.status = NotificationStatus.FAILED
|
|
notification.error_message = "發送失敗"
|
|
|
|
db.commit()
|
|
return sent_count
|
|
|
|
|
|
def send_delay_notification(db: Session, report: Report) -> int:
|
|
"""
|
|
發送延遲發布通知
|
|
|
|
Returns:
|
|
發送成功數量
|
|
"""
|
|
subscriptions = db.query(Subscription).filter(
|
|
Subscription.group_id == report.group_id,
|
|
Subscription.email_notify == True
|
|
).all()
|
|
|
|
sent_count = 0
|
|
|
|
for sub in subscriptions:
|
|
user = db.query(User).filter(User.id == sub.user_id).first()
|
|
if not user or not user.email or not user.is_active:
|
|
continue
|
|
|
|
# 轉義 HTML 特殊字元,防止 XSS
|
|
safe_group_name = escape(report.group.name)
|
|
html_content = f"""
|
|
<html>
|
|
<body>
|
|
<h2>報告延遲通知</h2>
|
|
<p>您訂閱的「{safe_group_name}」今日報告延遲發布,敬請稍後。</p>
|
|
<p>造成不便,敬請見諒。</p>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
success = send_email(
|
|
user.email,
|
|
f"【每日報導】{report.group.name} 報告延遲通知",
|
|
html_content
|
|
)
|
|
|
|
if success:
|
|
sent_count += 1
|
|
|
|
return sent_count
|