--- name: newsletter-publishing description: Email newsletter workflows. Use when creating newsletters, building subscriber lists, designing templates, or tracking engagement. --- # 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 ```markdown ## 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 ```markdown ## [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) ```html {{newsletter_name}}

{{newsletter_name}}

{{issue_date}}

{{content}}

You're receiving this because you subscribed to {{newsletter_name}}.

Unsubscribe | Update preferences

``` ### Python newsletter sender ```python 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 ```python 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 results def run_reengagement_campaign(inactive_subscribers: List[str]) -> None: """Send win-back campaign to inactive subscribers.""" # Send "We miss you" campaign # If no engagement after 2 attempts, mark for removal pass ``` ### Subscriber segmentation ```markdown ## Recommended segments ### By engagement - **VIPs**: Open rate > 80%, always click - **Engaged**: Open rate 40-80% - **Casual**: Open rate 10-40% - **At-risk**: Haven't opened in 90 days - **Inactive**: Haven't opened in 180 days ### By interest (tag-based) - Topic preferences from signup - Content they've clicked - Surveys/polls they've answered ### By source - Organic (website signup) - Referral (forwarded by friend) - Social media - Paywall/registration wall ``` ## Subject line optimization ### High-performing patterns ```markdown ## Subject line formulas that work ### For news/journalism - **Breaking format**: "Breaking: [Concise news]" - **Numbers**: "[X] things we learned about [topic]" - **Question**: "Why did [entity] do [thing]?" - **Direct**: "[Topic]: What you need to know" ### For analysis/opinion - **Take**: "The real story behind [event]" - **Contrarian**: "Why everyone is wrong about [topic]" - **Insider**: "What [industry] insiders know about [topic]" ### What to avoid - ALL CAPS - Excessive punctuation!!! - Clickbait that doesn't deliver - Spam trigger words (FREE, URGENT, ACT NOW) - Misleading preview text ``` ### A/B testing framework ```python import random from typing import List, Tuple def ab_test_subject_lines(subscribers: List[Subscriber], subject_a: str, subject_b: str, test_percentage: float = 0.2) -> dict: """ Test two subject lines on subset before full send. """ test_size = int(len(subscribers) * test_percentage) test_group = random.sample(subscribers, test_size) # Split test group half = len(test_group) // 2 group_a = test_group[:half] group_b = test_group[half:] remaining = [s for s in subscribers if s not in test_group] return { 'group_a': { 'subject': subject_a, 'subscribers': group_a, 'size': len(group_a) }, 'group_b': { 'subject': subject_b, 'subscribers': group_b, 'size': len(group_b) }, 'remaining': { 'subscribers': remaining, 'size': len(remaining), 'note': 'Send winner to this group after test period' }, 'test_duration_hours': 4 } ``` ## Deliverability best practices ### Email authentication setup ```markdown ## DNS records for deliverability ### SPF record ``` v=spf1 include:_spf.{{esp_sending_domain}} ~all ``` ### DKIM - Generate keys through your ESP - Add TXT record with public key - Verify signature is applied to outgoing mail ### DMARC ``` v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com ``` ### Checklist before sending - [ ] SPF, DKIM, DMARC configured - [ ] Sending domain warmed up - [ ] List is clean (no hard bounces) - [ ] Unsubscribe link works - [ ] Physical address in footer (CAN-SPAM) - [ ] Test email received in inbox (not spam) ``` ### Gmail, Yahoo, and Outlook bulk-sender requirements Bulk senders must meet authentication and unsubscribe requirements introduced by Gmail and Yahoo in February 2024. Microsoft Outlook adopted a parallel set in May 2025. Since November 2025, Gmail rejects non-compliant mail with permanent 5xx errors rather than soft-deferring it, non-compliant newsletters now bounce hard. **Who is covered.** A "bulk sender" is one mailing more than 5,000 messages per day to Gmail addresses. The 5,000 threshold is counted at the **primary domain level across all subdomains**, not per sending subdomain. A newsroom sending 2,500/day from `transactional.example.com` and 2,500/day from `news.example.com` is over the threshold. **Required:** - **SPF and DKIM authentication on the sending domain.** Both must pass. SPF alone is no longer sufficient. DKIM keys must be at least 1024 bits. - **DMARC policy at minimum `p=none`.** Production senders should move to `p=quarantine` or `p=reject` once aligned. - **Domain alignment.** **One** of SPF or DKIM must align with the organizational domain in the visible `From:` header, not both. Relaxed alignment is acceptable. - **One-click unsubscribe (RFC 8058).** The mail must include a `List-Unsubscribe` header with an HTTPS URL and a `List-Unsubscribe-Post: List-Unsubscribe=One-Click` header. The HTTPS endpoint must process the unsubscribe within two days without requiring login. A visible unsubscribe link must also appear in the message body. - **Spam complaint rate below 0.3 percent**, measured in Google Postmaster Tools. Google's recommended target ceiling is 0.1 percent; sustained rates above 0.3 percent trigger rejection. - **Valid PTR record (reverse DNS) on the sending IP.** Forward and reverse DNS must match. - **TLS for inbound connections** (Google requirement since December 2023). **Operational implications:** Most reputable ESPs handle authentication, headers, and TLS once the sending domain is verified. The two parts that remain the operator's responsibility are complaint rate and unsubscribe behavior, re-engagement campaigns and prompt list hygiene matter here. Re-engaging dormant subscribers is risky precisely because they complain at 5–10× the rate of active ones; one bad re-engagement campaign can push complaint rate over 0.3 percent and trigger rejections across the entire sending domain. References: - Google, *Email sender guidelines*, `support.google.com/mail/answer/81126` - Google, *Email sender guidelines FAQ*, `support.google.com/a/answer/14229414` - Yahoo, *Sender Best Practices*, `senders.yahooinc.com/best-practices/` ### Spam score checklist ```markdown ## Before you send ### Content checks - [ ] No spam trigger words - [ ] Text-to-image ratio good (mostly text) - [ ] All links are to reputable domains - [ ] No URL shorteners (use full links) - [ ] Plain text version included ### Technical checks - [ ] From address matches sending domain - [ ] Reply-to address is monitored - [ ] Preheader text is set - [ ] Images have alt text - [ ] Links are not broken ``` ## Analytics and optimization ### Key metrics dashboard ```python from dataclasses import dataclass @dataclass class NewsletterAnalytics: """Track newsletter performance over time.""" issue: NewsletterIssue def summary(self) -> dict: return { 'issue_number': self.issue.issue_number, 'sent': self.issue.sent_count, 'delivered': self.issue.delivered_count, 'delivery_rate': self._pct(self.issue.delivered_count, self.issue.sent_count), 'opens': self.issue.opened_count, 'open_rate': self.issue.open_rate, 'clicks': self.issue.clicked_count, 'click_rate': self.issue.click_rate, 'click_to_open': self._pct(self.issue.clicked_count, self.issue.opened_count), 'unsubscribes': self.issue.unsubscribed_count, 'unsubscribe_rate': self._pct(self.issue.unsubscribed_count, self.issue.delivered_count), } def _pct(self, numerator: int, denominator: int) -> float: if denominator == 0: return 0.0 return round((numerator / denominator) * 100, 2) # Benchmarks (journalism newsletters) BENCHMARKS = { 'open_rate': {'good': 40, 'excellent': 55}, 'click_rate': {'good': 4, 'excellent': 8}, 'unsubscribe_rate': {'acceptable': 0.5, 'concerning': 1.0}, } ``` ## Platform comparison | Platform | Best for | Pricing model | Key feature | |----------|----------|---------------|-------------| | Substack | Writer-first, paid subs | Revenue share | Built-in payments | | Buttondown | Developers, minimal | Per subscriber | Markdown native | | Ghost | Publishers, memberships | Flat fee | Full CMS included | | beehiiv | Growth-focused | Freemium | Referral tools | | Kit (formerly ConvertKit) | Creators | Per subscriber | Automation | | Mailchimp | Small orgs | Tiered | Easy templates | ## Legal compliance ### CAN-SPAM requirements (US) ```markdown - [ ] Accurate "From" name and email - [ ] Non-deceptive subject line - [ ] Physical postal address included - [ ] Working unsubscribe mechanism - [ ] Unsubscribe honored within 10 days - [ ] No purchased lists ``` ### GDPR requirements (EU subscribers) ```markdown - [ ] Explicit consent obtained (not pre-checked) - [ ] Clear privacy policy linked - [ ] Easy unsubscribe process - [ ] Data export available on request - [ ] Data deletion on request - [ ] Record of consent stored ``` ## Related skills - **web-scraping**, Automate content gathering for newsletters - **data-journalism**, Include data visualizations in emails - **academic-writing**, Write clear, structured content - **newsroom-style**, AP Style enforcement on newsletter copy - **fact-check-workflow**, Verify claims before they hit subscribers' inboxes - **ai-writing-detox**, Strip AI patterns from drafts --- ## Skill metadata | Field | Value | |-------|-------| | version | 1.0.0 | | created | 2025-12-26 | | updated | 2026-05-08 | | author | Joe Amditis | | domain | publishing, marketing | | complexity | intermediate |