Content & Marketing

Generate Email Newsletters with Python and AI

This guide shows you how to turn a messy list of links and notes into a formatted, ready-to-send HTML email newsletter in under fifteen minutes. You collect your items in Python, hand them to a language model (an LLM — the kind of AI that writes text) to draft subject lines and sections, render the result as email-safe HTML, and optionally send it through an email API. No design tools, no copy-paste shuffle.

The pattern is simple: notes in, polished newsletter out. Once it works, running next week's edition is one command. The real win is consistency — most newsletters die because writing one from scratch every week is a chore. When the format, subject lines, and HTML are handled for you, all that is left is gathering links, which you already do as you read.

This guide is part of AI Copywriting Workflows, and it reuses the same core skill — prompting a model and parsing its reply — that powers the rest of the AI Content Creation & Marketing Automation track.

Prerequisites

You only need a few things beyond a working Python 3.10+ setup. If Python is new to you, start with Setting Up Python for AI and come back. Install the three libraries we use:

pip install openai httpx python-dotenv

Here openai is the official SDK (software development kit — the helper library that talks to the model), httpx is a modern HTTP client we use to call an email service, and python-dotenv loads secrets from a file.

Create a file named .env in your project folder for your keys:

OPENAI_API_KEY=sk-your-openai-key-here
RESEND_API_KEY=re_your-resend-key-here

Add .env to your .gitignore immediately so you never commit a key to version control. If you do not have an OpenAI key yet, see Best Free AI APIs for Beginners for free options. The RESEND_API_KEY is only needed for Step 5 (sending); you can skip it while you build the draft.

Newsletter generation flow Links and notes flow into a language model, which returns subject lines and sections, which Python renders to HTML and sends through an email API. Links + notes (Python list) LLM drafts subjects, sections Render HTML (email template) Email API (httpx send)
The whole pipeline: your notes become drafted copy, then HTML, then a sent email.

The model writes better copy when you give it your own words, not just URLs. Represent each item as a small dictionary with a title, a link, and a one- or two-sentence note. The note is what stops the AI from inventing facts — it summarizes your summary.

items = [
    {
        "title": "OpenAI ships structured outputs",
        "url": "https://example.com/structured-outputs",
        "note": "Models can now return guaranteed-valid JSON. Big for parsing.",
    },
    {
        "title": "Our new pricing page is live",
        "url": "https://example.com/pricing",
        "note": "Simpler tiers, annual discount, founder plan added.",
    },
    {
        "title": "Weekend read: how small teams ship fast",
        "url": "https://example.com/small-teams",
        "note": "Long essay on keeping scope tight. Worth skimming.",
    },
]

Keep notes short and factual. You can store this list in your script, read it from a CSV, or pull it from a database — the rest of the pipeline does not care where it comes from. A practical habit: keep a running links.csv open all week and drop in a title, URL, and note whenever something is worth sharing. By send day the hard part is already done, and reading the file into the items list is a few lines of Python. If your CSV needs tidying first, Cleaning CSV Data with Pandas for AI covers the common fixes.

2. Generate subject lines and sections with the LLM

Now hand the items to the model and ask for structured output: a few subject-line options plus one written section per item. Asking for JSON (a simple text format of keys and values) makes the result easy to parse in Python. We set response_format to force valid JSON so you never have to clean up stray text. For more on shaping output, see Write System Prompts that Control Output Format.

import os
import json
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

SYSTEM_PROMPT = (
    "You are a newsletter editor. Write only from the notes provided; "
    "never invent facts. Keep a warm, concise tone. Return JSON with keys: "
    "'subjects' (list of 3 short subject-line options) and 'sections' "
    "(list of objects with 'heading', 'body' of 2-3 sentences, and 'url')."
)


def draft_newsletter(items: list[dict], topic: str) -> dict:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {
                "role": "user",
                "content": (
                    f"This week's newsletter is about: {topic}.\n"
                    f"Items as JSON:\n{json.dumps(items, indent=2)}"
                ),
            },
        ],
        temperature=0.6,
        response_format={"type": "json_object"},
    )
    return json.loads(response.choices[0].message.content)


draft = draft_newsletter(items, topic="AI tooling and company updates")
print(draft["subjects"])

The temperature of 0.6 keeps the copy lively without drifting off-brief. If the tone feels off, adjust the system prompt rather than the temperature — explicit instructions beat dial-twiddling. To pin down voice, add a sentence like "Write in the first person, address the reader as 'you', and keep each section under 60 words." Small, specific rules like that move the output more reliably than any numeric setting.

Notice that the system prompt does two jobs at once: it sets the tone and it defines the exact JSON shape the rest of the script depends on. Keeping both in one place means that when you change the format — say, adding a read_time field per section — you only edit one string. If you hit a JSONDecodeError, see Fix JSONDecodeError with AI API Responses in Python.

3. Render the newsletter as HTML

Email clients are stuck in the past, so the safest HTML is plain and uses inline styles (styles written directly on each tag). The function below builds one section per item and picks the first subject line. It returns both the subject and the HTML body so Step 5 can send them.

def render_html(draft: dict) -> tuple[str, str]:
    subject = draft["subjects"][0]
    blocks = []
    for s in draft["sections"]:
        blocks.append(
            f'<h2 style="font-size:18px;margin:24px 0 8px;color:#111;">'
            f'{s["heading"]}</h2>'
            f'<p style="font-size:15px;line-height:1.5;color:#333;margin:0 0 8px;">'
            f'{s["body"]}</p>'
            f'<p style="margin:0 0 16px;">'
            f'<a href="{s["url"]}" style="color:#2563eb;">Read more &rarr;</a></p>'
        )
    body = (
        '<div style="max-width:600px;margin:0 auto;padding:24px;'
        'font-family:Arial, sans-serif;">'
        f'<h1 style="font-size:22px;color:#111;">{subject}</h1>'
        + "".join(blocks)
        + '<hr style="border:none;border-top:1px solid #eee;margin:24px 0;">'
        '<p style="font-size:12px;color:#888;">You are receiving this because '
        'you subscribed. <a href="{{unsubscribe_url}}">Unsubscribe</a>.</p>'
        "</div>"
    )
    return subject, body


subject, html = render_html(draft)

Always include an unsubscribe link — most email services and anti-spam laws require one. The {{unsubscribe_url}} placeholder is replaced by your email provider at send time. Save html to a file and open it in a browser to preview before sending.

4. Preview and save the draft

Before spending a send, write the HTML to disk and eyeball it. This also gives you a record of every edition.

from pathlib import Path

Path("output").mkdir(exist_ok=True)
Path("output/newsletter.html").write_text(html, encoding="utf-8")
print(f"Saved. Subject: {subject}")

Open output/newsletter.html in any browser. If a section reads awkwardly, edit the note in Step 1 and rerun — the model only knows what you tell it.

5. Send it through an email API with httpx

Once the preview looks right, send it. This example uses Resend, which has a simple API and a free tier, but Postmark and SendGrid work the same way — only the URL and field names change. We use httpx to POST (send) the email as JSON.

import httpx


def send_newsletter(subject: str, html: str, to: str, sender: str) -> dict:
    response = httpx.post(
        "https://api.resend.com/emails",
        headers={
            "Authorization": f"Bearer {os.getenv('RESEND_API_KEY')}",
            "Content-Type": "application/json",
        },
        json={
            "from": sender,
            "to": [to],
            "subject": subject,
            "html": html,
        },
        timeout=30.0,
    )
    response.raise_for_status()
    return response.json()


result = send_newsletter(
    subject, html, to="you@example.com", sender="news@yourdomain.com"
)
print("Sent:", result.get("id"))

Always send a test to yourself first. The raise_for_status() call turns any failure (bad key, unverified domain) into a clear error instead of a silent miss. To email a whole list, loop over your subscribers — but batch carefully and respect your provider's rate limits.

Parameter quick-reference

ParameterTypeDefaultEffect
modelstrgpt-4o-miniWhich LLM drafts the copy. Larger models cost more but handle nuanced tone better.
temperaturefloat0.6Creativity of the writing. Lower is safer and more literal; higher is more varied.
response_formatdict{"type": "json_object"}Forces valid JSON so Python can parse sections without cleanup.
timeoutfloat30.0Seconds httpx waits for the email API before failing instead of hanging.

Troubleshooting

  1. The model returns text outside the JSON, breaking json.loads(). Cause: the prompt did not firmly require JSON. Fix: keep response_format={"type": "json_object"} set and include the word "JSON" in your system prompt, which the format mode requires.
  2. Email arrives but shows raw HTML tags as text. Cause: you sent the HTML in the API's plain-text field. Fix: pass your markup in the html field (as shown), not the text field.
  3. Resend returns 403 with a domain error. Cause: your from domain is not verified. Fix: verify your sending domain in the Resend dashboard, or use their onboarding@resend.dev test address while developing.
  4. Styles vanish in Outlook. Cause: Outlook ignores <style> blocks and many CSS properties. Fix: keep styles inline on each tag (as the template does) and avoid flexbox or grid layouts.

When to use this vs. alternatives

  • Use this Python approach when you send a recurring newsletter built from links and notes you already collect, and you want the drafting and formatting automated end to end. It scales to many editions for the cost of a few cents each.
  • Use a no-code tool like Mailchimp or Beehiiv when you need drag-and-drop design, audience analytics, and signup forms more than automation, and you only send occasionally. The visual editor is faster for one-off, design-heavy sends.
  • Use a fuller content pipeline when newsletters are one output among many. If you also publish long-form posts, pair this with Generate Blog Posts with the OpenAI API so the same notes feed both, and reuse the rewriting patterns from Bulk-Rewrite Product Descriptions with Python.

Back to AI Copywriting Workflows.