first commit

This commit is contained in:
2026-01-26 12:34:00 +01:00
commit e64465a7e6
29 changed files with 2952 additions and 0 deletions

1
src/email/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Email generation and sending"""

114
src/email/generator.py Normal file
View 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
View 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

View 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>