Newsletter publishing
Practical workflows for building and managing email newsletters for journalism and academia.
When to activate
- Creating a new newsletter from scratch
- Designing email templates for journalism content
- Building and segmenting subscriber lists
- Analyzing newsletter performance metrics
- Planning editorial calendars for newsletters
- Migrating between newsletter platforms
- Improving deliverability and open rates
Newsletter architecture
Content strategy framework
## Newsletter strategy document
### Core identity
- **Name**:
- **Tagline** (one line):
- **What readers get**: [specific value proposition]
- **Frequency**: [ ] Daily [ ] Weekly [ ] Bi-weekly [ ] Monthly
### Target audience
- Primary reader:
- What they care about:
- Why they'll subscribe:
- What they'll do with this info:
### Content pillars
1. [Core topic 1] - [how often]
2. [Core topic 2] - [how often]
3. [Recurring feature] - [how often]
### Voice and tone
- Formal ↔ Conversational: [1-5]
- Serious ↔ Light: [1-5]
- Reported ↔ Personal: [1-5]
### Success metrics (first 6 months)
- Subscriber goal:
- Target open rate:
- Target click rate:
Issue structure template
## [Newsletter Name] - Issue #[XX]
**Date**: [Date]
**Subject line**: [Subject]
**Preview text**: [First 50-90 characters readers see]
---
### Opening hook
[2-3 sentences that make readers want to keep reading]
### Main story
[Your primary content - 300-600 words for most newsletters]
### Secondary items (if applicable)
- **Quick hit 1**: [Brief item with link]
- **Quick hit 2**: [Brief item with link]
### Recurring section
[Weekly column, data point, recommendation, etc.]
### Sign-off
[Personal note, call to action, or preview of next issue]
---
**Unsubscribe** | **Preferences** | **Forward to a friend**
Technical implementation
HTML email template (responsive)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{newsletter_name}}</title>
<style>
/* Reset styles for email clients */
body { margin: 0; padding: 0; width: 100%; }
table { border-collapse: collapse; }
img { border: 0; display: block; }
/* Responsive container */
.container {
max-width: 600px;
margin: 0 auto;
font-family: Georgia, serif;
font-size: 18px;
line-height: 1.6;
color: #333;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.container { background-color: #1a1a1a; color: #e0e0e0; }
a { color: #6db3f2; }
}
/* Mobile styles */
@media only screen and (max-width: 480px) {
.container { padding: 15px !important; }
h1 { font-size: 24px !important; }
}
</style>
</head>
<body>
<table role="presentation" width="100%">
<tr>
<td align="center" style="padding: 20px;">
<div class="container">
<!-- Header -->
<table width="100%">
<tr>
<td style="padding-bottom: 20px; border-bottom: 2px solid #333;">
<h1 style="margin: 0;">{{newsletter_name}}</h1>
<p style="margin: 5px 0 0; color: #666;">{{issue_date}}</p>
</td>
</tr>
</table>
<!-- Content -->
<table width="100%">
<tr>
<td style="padding: 30px 0;">
{{content}}
</td>
</tr>
</table>
<!-- Footer -->
<table width="100%">
<tr>
<td style="padding-top: 20px; border-top: 1px solid #ddd; font-size: 14px; color: #666;">
<p>You're receiving this because you subscribed to {{newsletter_name}}.</p>
<p>
<a href="{{unsubscribe_url}}">Unsubscribe</a> |
<a href="{{preferences_url}}">Update preferences</a>
</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</body>
</html>
Python newsletter sender
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Optional
from enum import Enum
import hashlib
class SubscriberStatus(Enum):
ACTIVE = "active"
UNSUBSCRIBED = "unsubscribed"
BOUNCED = "bounced"
COMPLAINED = "complained"
@dataclass
class Subscriber:
email: str
name: Optional[str] = None
subscribed_at: datetime = field(default_factory=datetime.now)
status: SubscriberStatus = SubscriberStatus.ACTIVE
tags: List[str] = field(default_factory=list)
custom_fields: Dict = field(default_factory=dict)
@property
def hash_id(self) -> str:
"""Generate unique ID for unsubscribe links."""
return hashlib.md5(self.email.encode()).hexdigest()[:12]
@dataclass
class NewsletterIssue:
subject: str
preview_text: str
html_content: str
plain_text: str
scheduled_at: Optional[datetime] = None
sent_at: Optional[datetime] = None
issue_number: int = 0
# Metrics
sent_count: int = 0
delivered_count: int = 0
opened_count: int = 0
clicked_count: int = 0
bounced_count: int = 0
unsubscribed_count: int = 0
@property
def open_rate(self) -> float:
if self.delivered_count == 0:
return 0.0
return (self.opened_count / self.delivered_count) * 100
@property
def click_rate(self) -> float:
if self.delivered_count == 0:
return 0.0
return (self.clicked_count / self.delivered_count) * 100
class NewsletterManager:
"""Core newsletter operations."""
def __init__(self, name: str):
self.name = name
self.subscribers: List[Subscriber] = []
self.issues: List[NewsletterIssue] = []
def add_subscriber(self, email: str, name: str = None,
tags: List[str] = None) -> Subscriber:
"""Add new subscriber with double opt-in pending."""
sub = Subscriber(
email=email.lower().strip(),
name=name,
tags=tags or []
)
self.subscribers.append(sub)
return sub
def segment_subscribers(self, tags: List[str] = None,
min_engagement: float = None) -> List[Subscriber]:
"""Get subscribers matching criteria."""
active = [s for s in self.subscribers
if s.status == SubscriberStatus.ACTIVE]
if tags:
active = [s for s in active
if any(t in s.tags for t in tags)]
return active
def calculate_engagement_score(self, subscriber: Subscriber) -> float:
"""Score subscriber engagement 0-100."""
# Implementation would track opens/clicks per subscriber
return 50.0 # Placeholder
Subscriber management
List hygiene workflow
from datetime import datetime, timedelta
def clean_subscriber_list(manager: NewsletterManager,
inactive_threshold_days: int = 180) -> dict:
"""Identify and handle inactive subscribers."""
cutoff = datetime.now() - timedelta(days=inactive_threshold_days)
results = {
'total': len(manager.subscribers),
'active': 0,
'inactive': [],
'bounced': [],
'unsubscribed': []
}
for sub in manager.subscribers:
if sub.status == SubscriberStatus.BOUNCED:
results['bounced'].append(sub.email)
elif sub.status == SubscriberStatus.UNSUBSCRIBED:
results['unsubscribed'].append(sub.email)
elif sub.status == SubscriberStatus.ACTIVE:
# Check last engagement
engagement = manager.calculate_engagement_score(sub)
if engagement < 10: # Very low engagement
results['inactive'].append(sub.email)
else:
results['active'] += 1
return resul