first commit
This commit is contained in:
1
src/email/__init__.py
Normal file
1
src/email/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Email generation and sending"""
|
||||
114
src/email/generator.py
Normal file
114
src/email/generator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Email HTML generation from digest data"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from premailer import transform
|
||||
|
||||
from ..storage.models import DigestEntry
|
||||
from ..logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class EmailGenerator:
|
||||
"""Generate HTML emails from digest data"""
|
||||
|
||||
def __init__(self):
|
||||
# Set up Jinja2 template environment
|
||||
template_dir = Path(__file__).parent / "templates"
|
||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
def generate_digest_email(
|
||||
self, entries: list[DigestEntry], date_str: str, subject: str
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Generate HTML email for daily digest
|
||||
|
||||
Args:
|
||||
entries: List of digest entries (articles with summaries)
|
||||
date_str: Date string for the digest
|
||||
subject: Email subject line
|
||||
|
||||
Returns:
|
||||
Tuple of (html_content, text_content)
|
||||
"""
|
||||
# Group articles by category
|
||||
articles_by_category = defaultdict(list)
|
||||
for entry in entries:
|
||||
articles_by_category[entry.category].append(entry)
|
||||
|
||||
# Sort categories
|
||||
sorted_categories = sorted(articles_by_category.keys())
|
||||
|
||||
# Get unique sources count
|
||||
unique_sources = len(set(entry.article.source for entry in entries))
|
||||
|
||||
# Prepare template data
|
||||
template_data = {
|
||||
"title": subject,
|
||||
"date": date_str,
|
||||
"total_articles": len(entries),
|
||||
"total_sources": unique_sources,
|
||||
"total_categories": len(sorted_categories),
|
||||
"articles_by_category": {cat: articles_by_category[cat] for cat in sorted_categories},
|
||||
}
|
||||
|
||||
# Render HTML template
|
||||
template = self.env.get_template("daily_digest.html")
|
||||
html = template.render(**template_data)
|
||||
|
||||
# Inline CSS for email compatibility
|
||||
html_inlined = transform(html)
|
||||
|
||||
# Generate plain text version
|
||||
text = self._generate_text_version(entries, date_str, subject)
|
||||
|
||||
logger.info(f"Generated email with {len(entries)} articles")
|
||||
|
||||
return html_inlined, text
|
||||
|
||||
def _generate_text_version(
|
||||
self, entries: list[DigestEntry], date_str: str, subject: str
|
||||
) -> str:
|
||||
"""Generate plain text version of email"""
|
||||
lines = [
|
||||
subject,
|
||||
"=" * len(subject),
|
||||
"",
|
||||
f"Date: {date_str}",
|
||||
f"Total Articles: {len(entries)}",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
|
||||
# Group by category
|
||||
articles_by_category = defaultdict(list)
|
||||
for entry in entries:
|
||||
articles_by_category[entry.category].append(entry)
|
||||
|
||||
# Output each category
|
||||
for category in sorted(articles_by_category.keys()):
|
||||
lines.append(f"{category.upper()}")
|
||||
lines.append("-" * len(category))
|
||||
lines.append("")
|
||||
|
||||
for entry in articles_by_category[category]:
|
||||
article = entry.article
|
||||
lines.append(f"• {article.title}")
|
||||
lines.append(f" Source: {article.source}")
|
||||
lines.append(f" Published: {article.published.strftime('%B %d, %Y at %H:%M')}")
|
||||
lines.append(f" Relevance: {entry.relevance_score:.1f}/10")
|
||||
lines.append(f" URL: {article.url}")
|
||||
lines.append(f" Summary: {entry.ai_summary}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("")
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("Generated by News Agent | Powered by OpenRouter AI")
|
||||
|
||||
return "\n".join(lines)
|
||||
77
src/email/sender.py
Normal file
77
src/email/sender.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Email sending via SMTP"""
|
||||
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.utils import formatdate
|
||||
|
||||
from ..config import get_config
|
||||
from ..logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
class EmailSender:
|
||||
"""Send emails via SMTP"""
|
||||
|
||||
def __init__(self):
|
||||
config = get_config()
|
||||
self.config = config.email
|
||||
|
||||
def send(self, subject: str, html_content: str, text_content: str) -> bool:
|
||||
"""
|
||||
Send email with HTML and plain text versions
|
||||
|
||||
Args:
|
||||
subject: Email subject line
|
||||
html_content: HTML email body
|
||||
text_content: Plain text email body
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create message
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{self.config.from_name} <{self.config.from_}>"
|
||||
msg["To"] = self.config.to
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
|
||||
# Attach parts
|
||||
part_text = MIMEText(text_content, "plain", "utf-8")
|
||||
part_html = MIMEText(html_content, "html", "utf-8")
|
||||
|
||||
msg.attach(part_text)
|
||||
msg.attach(part_html)
|
||||
|
||||
# Send via SMTP
|
||||
smtp_config = self.config.smtp
|
||||
|
||||
if smtp_config.use_ssl:
|
||||
server = smtplib.SMTP_SSL(smtp_config.host, smtp_config.port)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_config.host, smtp_config.port)
|
||||
|
||||
try:
|
||||
if smtp_config.use_tls and not smtp_config.use_ssl:
|
||||
server.starttls()
|
||||
|
||||
# Login if credentials provided
|
||||
if smtp_config.username and smtp_config.password:
|
||||
server.login(smtp_config.username, smtp_config.password)
|
||||
|
||||
# Send email
|
||||
server.send_message(msg)
|
||||
logger.info(f"Email sent successfully to {self.config.to}")
|
||||
return True
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP error sending email: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending email: {e}")
|
||||
return False
|
||||
194
src/email/templates/daily_digest.html
Normal file
194
src/email/templates/daily_digest.html
Normal file
@@ -0,0 +1,194 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
border-bottom: 3px solid #2563eb;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
h1 {
|
||||
color: #1e40af;
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.date {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
.summary {
|
||||
background-color: #eff6ff;
|
||||
border-left: 4px solid #2563eb;
|
||||
padding: 15px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.summary p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.category-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.category-header {
|
||||
background-color: #f3f4f6;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.category-title {
|
||||
color: #374151;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.article {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 25px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.article:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.article-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.article-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.article-title:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.article-meta {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.article-meta span {
|
||||
margin-right: 15px;
|
||||
}
|
||||
.score-badge {
|
||||
display: inline-block;
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.article-summary {
|
||||
color: #374151;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.read-more {
|
||||
display: inline-block;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.read-more:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #e5e7eb;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
body {
|
||||
padding: 10px;
|
||||
}
|
||||
.container {
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.article-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="date">{{ date }}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<p><strong>{{ total_articles }}</strong> articles curated from your personalized news sources</p>
|
||||
<p><strong>{{ total_sources }}</strong> sources | <strong>{{ total_categories }}</strong> categories</p>
|
||||
</div>
|
||||
|
||||
{% for category, articles in articles_by_category.items() %}
|
||||
<div class="category-section">
|
||||
<div class="category-header">
|
||||
<h2 class="category-title">{{ category }}</h2>
|
||||
</div>
|
||||
|
||||
{% for entry in articles %}
|
||||
<article class="article">
|
||||
<div class="article-header">
|
||||
<a href="{{ entry.article.url }}" class="article-title" target="_blank" rel="noopener">
|
||||
{{ entry.article.title }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="article-meta">
|
||||
<span>{{ entry.article.source }}</span>
|
||||
<span>{{ entry.article.published.strftime('%B %d, %Y at %H:%M') }}</span>
|
||||
<span class="score-badge">Relevance: {{ "%.1f"|format(entry.relevance_score) }}/10</span>
|
||||
</div>
|
||||
|
||||
<div class="article-summary">
|
||||
{{ entry.ai_summary }}
|
||||
</div>
|
||||
|
||||
<a href="{{ entry.article.url }}" class="read-more" target="_blank" rel="noopener">
|
||||
Read full article →
|
||||
</a>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by News Agent | Powered by OpenRouter AI</p>
|
||||
<p>You received this because you subscribed to daily tech news digests</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user