Content & Marketing

Generate Email Newsletters with Python and AI

Turn a list of links and notes into a formatted HTML email newsletter with Python and an LLM — subject lines, sections, and optional API sending.

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. A local .env file is fine while you are drafting on your own machine; the moment this script runs somewhere else — a server, a scheduler, a colleague's laptop — read Manage API Keys Safely in Production first.

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. When a rule is easier to demonstrate than to describe — your house style for a section opener, say — paste one section you already wrote into the prompt as a model answer; Few-Shot Prompting in Python with Examples shows how to structure those examples so the model copies the pattern rather than the content.

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.

It is worth knowing exactly what draft holds once json.loads() has run, because every later step reads from it. The diagram below labels each part of that object and where it ends up in the finished email.

Anatomy of the JSON object the model returns The parsed reply is a dictionary with two keys: subjects, a list of three candidate subject lines of which the script uses the first, and sections, a list of objects that each carry a heading, a body and a url and become one block of the email. draft (dict) parsed JSON reply subjects list of 3 strings pick subjects[0] becomes the subject sections list of objects heading, body, url one block per item
Every field the model returns has exactly one job downstream: the first subject line becomes the email subject, and each section object becomes one heading, paragraph and link in the body.

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.

Read from the outside in, the template is only five layers deep, and every edition you ever send has the same five. Knowing the order makes it easy to slot in something new — a sponsor block above the sections, say — without breaking the parts that already render correctly.

The five layers of the email HTML template From outside in, the rendered email is a fixed-width wrapper div, an h1 holding the subject line, a repeated block of section markup, a horizontal rule, and a small footer carrying the unsubscribe link. render order wrapper <div> max-width 600px <h1> headline reuses subjects[0] section blocks heading, body, link <hr> divider 1px grey line footer unsubscribe link
Everything the render function emits belongs to one of these five layers, so adding a block is a matter of choosing which layer it sits between.

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.

Three parties are involved in that one function call, and it helps to know which of them you can still hear from. Your script only ever talks to the email service; delivery to the inbox happens afterwards, out of your sight, which is why a 200 OK means "accepted", not "read".

What happens between your script and the inbox A sequence diagram: the script posts the subject and HTML to the email service, the service queues the message for delivery to the recipient, and returns an acceptance response with a message id to the script. your script httpx.post email service Resend endpoint recipient inbox Gmail, Outlook POST subject + html queued for delivery accepted + id raise_for_status surfaces failures
Your script gets one answer, from the email service only — so check its status code, then rely on the provider's dashboard for what happened after the handoff.

Once a send works by hand, the natural next move is to stop running it by hand. A weekly edition is a good first candidate for Schedule Python AI Jobs with GitHub Actions, which runs the script on a timer and keeps your keys out of the repository.

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.

Only model moves the bill in any meaningful way, and a newsletter is a small job: a handful of items in, a few hundred words out. If you want the real number for your own edition rather than a guess, Estimate OpenAI API Costs with Python shows how to read the token counts off each response and price them.

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.

Frequently asked questions

Can Python write an email newsletter from a list of links?

Yes. You collect your links and notes into a simple Python list, send them to a language model with a prompt that describes the format you want, and the model returns subject lines and section copy. Python then wraps that text in HTML so it renders as a real email.

Do I need a designer to make the HTML email look good?

No. Email clients ignore most modern CSS, so a plain, inline-styled HTML template is both safer and easier than a designed one. The small template in this guide renders correctly in Gmail, Outlook, and Apple Mail without any design tools.

Which model should I use for newsletter copy?

A small, fast model like gpt-4o-mini is enough for most newsletters and keeps costs near zero. Use a larger model only if your tone is very specific or your sections are long and analytical.

How do I actually send the email once it is generated?

Send it through an email API such as Resend, Postmark, or SendGrid using httpx, an HTTP library for Python. You pass your API key, the recipient, the subject, and the HTML body, and the service delivers it.

Will the AI invent facts about my links?

It can, if you give it only a URL. Always pass a short note or summary alongside each link and instruct the model to write only from the notes provided, so it summarizes your words instead of guessing.