ScreenshotNeo

BlogHow-to

How to Send an Image in Email Using Python

Build an inline image email or a downloadable attachment with Python’s email package and smtplib, including MIME, SMTP security, errors, and production tips.

By the ScreenshotNeo team1 October 20268 min read

Use Python’s email package to build the message and smtplib to deliver it through your mail provider’s SMTP server. If the picture should appear inside the message, create an HTML alternative, add the image as a related MIME part, and reference its Content-ID with cid:. If recipients should download the file, add it as a normal attachment instead.

This guide covers both choices, with a complete inline-image example first. Provider hostnames, ports, quotas, authentication methods, and account requirements vary, so obtain those values from your provider’s current SMTP documentation.

1. Choose inline display or attachment

Goal MIME approach Recipient experience
Show the image in the HTML message multipart/related, image with Content-ID The mail client can render the image beside the text.
Offer a file to download add_attachment() The image appears as a conventional attachment.
Do both Add a related image and a separate attachment Inline preview plus downloadable copy.

Inline rendering depends on the recipient’s mail client and settings. Always include useful alt text and a plain-text alternative.

2. Complete inline-image example

The following script reads an image, creates a matching Content-ID, builds plain-text and HTML alternatives, and sends the message using STARTTLS. Save it as send_inline_image.py.

import mimetypes
import os
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import make_msgid
from pathlib import Path

sender = os.environ["EMAIL_SENDER"]
recipient = os.environ["EMAIL_RECIPIENT"]
image_path = Path(os.environ.get("IMAGE_PATH", "image.png"))

mime_type, _ = mimetypes.guess_type(image_path.name)
if not mime_type or not mime_type.startswith("image/"):
    raise ValueError(f"Unsupported or unknown image type: {image_path}")
maintype, subtype = mime_type.split("/", 1)

image_bytes = image_path.read_bytes()
image_cid = make_msgid()

msg = EmailMessage()
msg["Subject"] = "An image from Python"
msg["From"] = sender
msg["To"] = recipient
msg.set_content(
    "This message contains an image. If your mail client cannot display HTML, "
    "open the attached image or view the message in an HTML-capable client."
)
msg.add_alternative(
    f"""
  
    

Here is the image:

Image sent by Python """, subtype="html", ) # The last payload is the HTML part. Add the image as a related inline resource. html_part = msg.get_payload()[-1] html_part.add_related( image_bytes, maintype=maintype, subtype=subtype, cid=image_cid, filename=image_path.name, ) context = ssl.create_default_context() smtp_host = os.environ["SMTP_HOST"] smtp_port = int(os.environ.get("SMTP_PORT", "587")) smtp_password = os.environ["SMTP_PASSWORD"] with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as smtp: smtp.ehlo() smtp.starttls(context=context) smtp.ehlo() # Re-identify after upgrading the connection to TLS. smtp.login(sender, smtp_password) smtp.send_message(msg) print("Message submitted")

Set the environment variables before running it:

export EMAIL_SENDER='sender@example.com'
export EMAIL_RECIPIENT='recipient@example.net'
export SMTP_HOST='smtp.example.com'
export SMTP_PORT='587'
export SMTP_PASSWORD='provider-issued-secret'
export IMAGE_PATH='image.png'
python send_inline_image.py

make_msgid() returns a bracketed ID such as <...>. The HTML uses the bare value inside cid:, while add_related() receives the bracketed value as the MIME Content-ID. Those values must refer to the same ID.

3. Add a downloadable attachment

Use add_attachment() when the image should be presented as a file. This is separate from an inline related part:

from email.message import EmailMessage
from pathlib import Path
import mimetypes

image_path = Path("image.png")
image_bytes = image_path.read_bytes()
mime_type, _ = mimetypes.guess_type(image_path.name)
if not mime_type or not mime_type.startswith("image/"):
    raise ValueError("The file is not a recognized image")
maintype, subtype = mime_type.split("/", 1)

msg = EmailMessage()
msg["Subject"] = "Your requested image"
msg["From"] = "sender@example.com"
msg["To"] = "recipient@example.net"
msg.set_content("The image is attached to this message.")
msg.add_attachment(
    image_bytes,
    maintype=maintype,
    subtype=subtype,
    filename=image_path.name,
)

To provide both experiences, call add_alternative() and add_related() as in the inline example, then add a second copy with add_attachment(). Consider the resulting message size and whether duplicate content is useful to recipients.

4. Configure SMTP securely

EmailMessage constructs the MIME message; smtplib handles SMTP communication. Python documents send_message() for sending a message object and starttls() for upgrading a connection to TLS. After starttls(), issue ehlo() again before authentication or sending. See the email package documentation and smtplib documentation.

STARTTLS on port 587

Use smtplib.SMTP(host, 587), call starttls(context=ssl.create_default_context()), then authenticate. Do not send credentials before the TLS upgrade.

Implicit TLS on a provider-specific port

Some providers document an implicit TLS endpoint, commonly exposed through smtplib.SMTP_SSL. Use the host and port specified by that provider:

import smtplib
import ssl

context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.example.com", 465, context=context, timeout=30) as smtp:
    smtp.login(sender, password)
    smtp.send_message(msg)

Do not assume that a normal account password will work. A provider may require an app password, OAuth, an enabled SMTP feature, a verified sender, or a different authentication policy. smtplib.login() negotiates an advertised authentication method and raises an error when the server rejects the credentials or offers no supported method.

5. Image formats, MIME types, and message structure

  • Use the actual subtype: image/png, image/jpeg, image/gif, or another type your provider and recipients support.
  • Do not label a JPEG as PNG; incorrect MIME metadata can prevent rendering.
  • Keep a plain-text body for clients that block HTML or related resources.
  • Use descriptive alt text. It is shown when images are blocked and supports accessibility.
  • Escape untrusted text inserted into HTML. Avoid inserting untrusted URLs or markup directly into the body.
  • Some clients block remote images, but a CID resource is carried in the message itself. Client behavior still varies.

6. Troubleshooting common errors

Symptom Likely cause Fix
Image shows as a broken icon The HTML CID does not match the MIME Content-ID. Use image_cid[1:-1] in cid: and the full image_cid in add_related().
Attachment downloads with the wrong type Hard-coded or incorrect MIME subtype. Detect the type with mimetypes.guess_type() or set the known type for the file.
SMTPAuthenticationError Wrong secret, disabled SMTP, sender mismatch, or provider policy. Check the provider’s current authentication instructions, app-password/OAuth requirements, and allowed sender.
SMTPServerDisconnected or timeout Wrong host/port, firewall, transient network issue, or an idle connection. Verify endpoint settings, set a timeout, retry transient failures, and log the SMTP response without logging credentials.
TLS negotiation fails Using STARTTLS on an implicit-TLS endpoint, or vice versa. Match the provider’s documented connection mode and port; use a default SSL context.
Message is accepted but never arrives Provider filtering, quota, recipient rejection, or spam placement. Inspect provider delivery logs, verify the recipient, check quotas and bounce messages, and authenticate the sending domain when required.
Only plain text appears Recipient client blocked HTML or the alternative was not added. Confirm add_alternative(..., subtype="html") is called and keep the plain-text copy useful.

7. Performance, reliability, and cost

Performance

  • Read the image once with Path.read_bytes(); avoid repeatedly opening it during message construction.
  • Resize or compress unnecessarily large source images before encoding. Base64 MIME encoding increases transfer size.
  • For multiple recipients, follow your provider’s batching and rate limits. Reuse a connection only when the provider permits it and handle disconnects.
  • Set explicit connect and read timeouts so a worker cannot wait forever.

Reliability

  • Retry only transient network or server failures, with bounded exponential backoff and a maximum attempt count.
  • Do not retry permanent authentication, policy, or invalid-recipient errors without changing the configuration.
  • Record message IDs, provider responses, and attempt outcomes, but never passwords or OAuth tokens.
  • Make jobs idempotent so a retry does not unintentionally send duplicates; use an application-level delivery key where your provider supports one.

Cost and limits

Python’s standard library does not charge for constructing or sending a message. Your SMTP provider controls quotas, attachment limits, per-minute rates, and any paid usage. Check those current terms before choosing image sizes or sending volume. Large inline images consume message and mailbox storage even when they are not separately downloaded.

8. Or skip the browser setup

If the image you want to email is a screenshot of a web page, ScreenshotNeo can create the image before your Python mail code runs. It accepts one GET request and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, failed loads, timeouts, and cache hits are not billed. Responses identify the result with X-Page-Verdict and X-Billed headers. Its MCP server also lets Claude, Cursor, and other MCP clients take screenshots.

See the ScreenshotNeo API documentation for all options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());

ScreenshotNeo includes full-page and element capture, custom CSS and JavaScript, device and retina settings, waits, headers, cookies, blocking rules, caching, signed links, asynchronous jobs, bulk capture, and PDF output. One thousand screenshots per month are free with no card; paid plans start at $5 for 3,000 shots. Create your free ScreenshotNeo account.

9. FAQ

Can I send an image without HTML?

Yes. Send a plain-text message with add_attachment(). Inline display requires an HTML part and a related MIME image.

Should I use a public image URL instead of CID?

A public URL keeps the message smaller, but clients may block remote images and the URL must remain available. CID embeds the image in the message and avoids that hosting dependency.

Why call EHLO twice?

The first greeting advertises server capabilities. After STARTTLS changes the connection state, the second greeting lets the server and client negotiate capabilities again.

Can I use Gmail, Outlook, or another provider?

Usually, if the account and provider policy permit SMTP submission. Use that provider’s current host, port, encryption, quota, and authentication instructions rather than copying values from another service.

It adds a related resource for the HTML body and defaults to inline disposition. Use add_attachment() for a conventional downloadable attachment.