{"id":364,"date":"2026-09-06T15:03:12","date_gmt":"2026-09-06T20:33:12","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=364"},"modified":"2026-09-06T15:03:13","modified_gmt":"2026-09-06T20:33:13","slug":"sending-email-in-python-smtplib-vs-an-email-api-with-working-code","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/sending-email-in-python-smtplib-vs-an-email-api-with-working-code\/","title":{"rendered":"Sending Email in Python: smtplib vs an Email API, With Working Code"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Your Python script sends email perfectly from your laptop. You deploy it to a server, add a bit of traffic, and it starts failing in ways that never showed up in testing: connections that hang for thirty seconds before timing out, authentication that suddenly gets rejected, or a script that sends the first ten emails fine and then locks up entirely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">None of this means smtplib is broken. It means smtplib was built as a thin, correct implementation of the SMTP protocol, and the protocol was never designed for a web application sending hundreds of emails a minute over an unreliable network. This guide covers both approaches: sending directly with smtplib the way most Python projects start, and where an HTTP-based email API takes over once smtplib&#8217;s limits start showing up in production.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: How Do You Send Email in Python?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Use the built-in <code>smtplib<\/code> module together with <code>email.message.EmailMessage<\/code> to build and send a message over an authenticated SMTP connection:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import smtplib\nfrom email.message import EmailMessage\n\nmsg = EmailMessage()\nmsg&#91;\"Subject\"] = \"Your verification code\"\nmsg&#91;\"From\"] = \"noreply@yourdomain.com\"\nmsg&#91;\"To\"] = \"user@example.com\"\nmsg.set_content(\"Your code is 492019.\")\n\nwith smtplib.SMTP(\"smtp.photonconsole.com\", 587) as server:\n    server.starttls()\n    server.login(\"your_project_api_user\", \"your_secret_api_key\")\n    server.send_message(msg)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The library ships with every Python install, so there is nothing to install. The part that determines whether this works in production is the host you connect to. An unauthenticated personal mailbox or an unverified server will fail under real traffic; an authenticated relay such as <a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonConsole<\/a> on port 587 with TLS is what keeps delivery working once you are past a handful of test emails.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What smtplib Actually Does<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib is Python&#8217;s built-in client for the SMTP protocol. It opens a socket, speaks the SMTP command sequence, and hands your message to a mail server. It does not compose messages, manage templates, track opens, or retry failures \u2014 that is what the <code>email<\/code> package and your own application code are for.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This matters because most &#8220;smtplib is broken&#8221; reports are actually one of two other things failing: the message wasn&#8217;t built to spec by the <code>email<\/code> package, or the server on the other end rejected the connection. If you want the protocol fundamentals first, see our guide to <a href=\"https:\/\/photonconsole.com\/blog\/what-is-smtp-direct-answer\/\">what SMTP is<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why smtplib Fails in Production<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most SMTP errors occur due to misconfiguration or authentication issues rather than defects in smtplib itself. These five causes account for the majority of Python email failures.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. Using SMTP() When the Server Requires SMTP_SSL()<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Python&#8217;s <a href=\"https:\/\/docs.python.org\/3\/library\/smtplib.html\" target=\"_blank\" rel=\"noopener\">smtplib documentation<\/a> is explicit that <code>SMTP_SSL<\/code> is for connections that are encrypted from the first byte, typically port 465, while <code>SMTP<\/code> combined with <code>.starttls()<\/code> upgrades an initially plain connection, typically port 587. Using the wrong pairing produces a connection that hangs until it times out, with no useful error message.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Blocking Calls With No Timeout<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">By default, <code>smtplib.SMTP()<\/code> will wait indefinitely for a response. A single slow or unresponsive server can freeze a request thread completely. Every production connection needs an explicit <code>timeout<\/code> argument.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Your Host Blocks Outbound Port 25<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">AWS, Google Cloud, DigitalOcean and Azure all restrict outbound traffic on port 25 by default to control spam abuse. If your code targets port 25, the connection attempt stalls and eventually errors out. AWS documents its <a href=\"https:\/\/repost.aws\/knowledge-center\/ec2-port-25-throttle\" target=\"_blank\" rel=\"noopener\">port 25 throttle removal process<\/a>, but removal does not solve the underlying reputation problem of sending from a fresh IP. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP connection timeouts<\/a> covers the full diagnosis.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. One Connection Per Email<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Opening a new <code>smtplib.SMTP<\/code> instance for every message forces a fresh TCP handshake and TLS negotiation each time. At any real volume this is slow, and it looks like abusive traffic to the receiving server, which starts throttling or blocking the connection.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. No Authentication Records on the Sending Domain<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Without SPF and DKIM configured, mailbox providers cannot confirm your server was authorised to send as your domain, and messages are filtered or rejected outright regardless of how correct your Python code is. This is covered fully in our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib Hangs and Never Returns<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Add an explicit <code>timeout=10<\/code> to your <code>SMTP()<\/code> or <code>SMTP_SSL()<\/code> call<\/li>\n\n\n\n<li>Confirm you are using port 587 with <code>.starttls()<\/code>, or port 465 with <code>SMTP_SSL()<\/code> \u2014 never mix the two patterns<\/li>\n\n\n\n<li>Try port 2525 if your host silently blocks 587 and 465 outbound<\/li>\n\n\n\n<li>Wrap the connection in a try\/except that catches <code>socket.timeout<\/code> specifically<\/li>\n\n\n\n<li>Confirm the host resolves with a DNS lookup before assuming the server is at fault<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Step-by-Step: Sending Email With smtplib<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Step 1: Build the Message With the email Package<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Never hand-build raw SMTP message text. Use <code>EmailMessage<\/code>, which handles headers, encoding and line-length rules correctly.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from email.message import EmailMessage\n\nmsg = EmailMessage()\nmsg&#91;\"Subject\"] = \"Your order has shipped\"\nmsg&#91;\"From\"] = \"orders@yourdomain.com\"\nmsg&#91;\"To\"] = \"customer@example.com\"\nmsg.set_content(\"Your order #10928 has shipped.\")\n\n# Optional HTML alternative\nmsg.add_alternative(\n    \"&lt;p&gt;Your order &lt;strong&gt;#10928&lt;\/strong&gt; has shipped.&lt;\/p&gt;\",\n    subtype=\"html\"\n)<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 2: Store Credentials as Environment Variables<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Never hardcode SMTP credentials in source. Read them from the environment and keep secrets out of version control.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import os\n\nSMTP_HOST = os.environ&#91;\"SMTP_HOST\"]\nSMTP_PORT = int(os.environ.get(\"SMTP_PORT\", 587))\nSMTP_USER = os.environ&#91;\"SMTP_USER\"]\nSMTP_PASS = os.environ&#91;\"SMTP_PASS\"]<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 3: Connect With an Explicit Timeout<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>import smtplib\n\ndef get_connection():\n    server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10)\n    server.starttls()\n    server.login(SMTP_USER, SMTP_PASS)\n    return server<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Opening a fresh <code>smtplib.SMTP<\/code> connection inside a loop or a per-request function is the single most common cause of slow, unreliable Python email sending. Every connection re-runs the TCP handshake and the TLS negotiation from zero. Open one connection, send everything you need through it, and close it \u2014 the pattern shown in Step 4.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 4: Reuse One Connection for Multiple Sends<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If you are sending several messages in the same process, open the connection once and reuse it rather than reconnecting per message.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def send_batch(messages):\n    with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:\n        server.starttls()\n        server.login(SMTP_USER, SMTP_PASS)\n        for msg in messages:\n            server.send_message(msg)<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Step 5: Handle Errors Explicitly<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib raises specific exceptions for specific failures. Catching the base <code>Exception<\/code> class hides which one actually happened.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import logging\n\ndef send_email(msg):\n    try:\n        with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:\n            server.starttls()\n            server.login(SMTP_USER, SMTP_PASS)\n            server.send_message(msg)\n        return True\n    except smtplib.SMTPAuthenticationError as e:\n        logging.error(\"SMTP auth rejected: %s\", e)\n    except smtplib.SMTPConnectError as e:\n        logging.error(\"Could not connect to SMTP host: %s\", e)\n    except smtplib.SMTPRecipientsRefused as e:\n        logging.error(\"Recipient refused: %s\", e.recipients)\n    except (TimeoutError, smtplib.SMTPServerDisconnected) as e:\n        logging.error(\"Connection timed out or dropped: %s\", e)\n    return False<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For interpreting the numeric codes inside these exceptions, our reference on <a href=\"https:\/\/photonconsole.com\/blog\/smtp-response-codes-explained\/\">SMTP response codes<\/a> maps each one to its cause.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Step 6: Add Retry Logic for Transient Failures<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A 4xx response is temporary; a 5xx response is permanent. Retrying a permanent failure wastes time and can damage your sender reputation.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import time\n\ndef send_with_retry(msg, attempts=3):\n    for i in range(attempts):\n        try:\n            return send_email(msg)\n        except smtplib.SMTPResponseException as e:\n            if 400 &lt;= e.smtp_code &lt; 500 and i &lt; attempts - 1:\n                time.sleep(2 ** i)  # 1s, 2s, 4s\n                continue\n            raise<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">For a deeper treatment of backoff strategy across an entire sending pipeline, see our guide to <a href=\"https:\/\/photonconsole.com\/blog\/smtp-retry-logic-explained-for-transactional-email-systems\/\">SMTP retry logic for transactional systems<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why smtplib Breaks Down at Scale<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"512\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic-1024x512.png\" alt=\"\" class=\"wp-image-366\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic-1024x512.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic-300x150.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic-768x384.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic-1536x768.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/Blocking-vs-Queued-Sending-Infographic.png 1774w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">A direct smtplib call blocks the calling process until the full SMTP conversation completes.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib is synchronous and blocking by design. Each <code>send_message()<\/code> call waits for the full SMTP conversation to complete before your code continues. That is fine for a script sending a handful of emails. It becomes the bottleneck once your application needs to send hundreds or thousands of messages without freezing the process that triggered them \u2014 regardless of which relay, including PhotonConsole, sits on the other end of the connection.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Approach<\/th><th>Good For<\/th><th>Breaks Down When<\/th><\/tr><tr><td>Direct smtplib call<\/td><td>Scripts, cron jobs, low-volume notifications<\/td><td>Called inside a request-response cycle or a tight loop<\/td><\/tr><tr><td>smtplib + background worker (Celery, RQ)<\/td><td>Web apps sending at moderate volume<\/td><td>Worker concurrency opens far more SMTP connections than the relay allows<\/td><\/tr><tr><td>HTTP email API<\/td><td>Serverless functions, high-volume transactional sending, async frameworks<\/td><td>Rarely \u2014 this is the pattern that scales furthest<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">smtplib vs an Email API<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"512\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-1024x512-1.png\" alt=\"\" class=\"wp-image-367\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-1024x512-1.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-300x150-1.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-768x384-1.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-1536x768-1.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/SMTP-vs-Email-API-Infographic-.png 1774w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">smtplib and an HTTP email API can reach the same delivery infrastructure through two different connection models.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">An HTTP-based email API replaces the SMTP conversation with a single authenticated POST request. The tradeoffs are specific, not a simple &#8220;API is better&#8221; story.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>smtplib stays the better choice when:<\/strong> you are running a traditional server process that can hold an open connection, you want zero dependency on an external SDK, or your volume is low enough that connection overhead never becomes visible.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>An email API becomes the better choice when:<\/strong> you are running in a serverless or edge environment where a persistent SMTP connection is impractical, you are using <code>asyncio<\/code> and want a non-blocking HTTP client instead of a blocking socket call, or your send volume is high enough that connection reuse and queuing add real operational complexity.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import requests\n\nresponse = requests.post(\n    \"https:\/\/api.photonconsole.com\/v1\/send\",\n    headers={\"Authorization\": \"Bearer YOUR_API_KEY\"},\n    json={\n        \"from\": \"alerts@yourdomain.com\",\n        \"to\": &#91;\"user@example.com\"],\n        \"subject\": \"Your verification code\",\n        \"html\": \"&lt;p&gt;Your code is 492019.&lt;\/p&gt;\"\n    },\n    timeout=10\n)\nresponse.raise_for_status()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Both approaches point at the same underlying delivery infrastructure. Choosing between them is a question of what fits your application&#8217;s execution model, not which one is more reliable. Our <a href=\"https:\/\/photonconsole.com\/blog\/email-api-integration\/\">email API integration guide<\/a> covers the HTTP approach in more depth.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring smtplib With PhotonConsole<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Moving from an ad hoc mailbox to a dedicated relay requires two things: DNS authentication and updated connection details. Neither requires changing how you build messages with the <code>email<\/code> package.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">DNS Records<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Add these to your DNS provider to authenticate your sending domain:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>TXT    @                    v=spf1 include:relay.photonconsole.com ~all\nCNAME  photon._domainkey    dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">DNS changes are not instant. SPF and DKIM records can take anywhere from a few minutes to 24-48 hours to propagate depending on your provider and TTL settings. Confirm propagation with a DNS lookup tool before assuming a record is misconfigured.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Connection Configuration<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>SMTP_HOST = \"smtp.photonconsole.com\"\nSMTP_PORT = 587       # 465 for implicit SSL, 2525 if 587 is blocked\nSMTP_USER = os.environ&#91;\"PHOTON_USER\"]\nSMTP_PASS = os.environ&#91;\"PHOTON_PASS\"]\n\nwith smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as server:\n    server.starttls()\n    server.login(SMTP_USER, SMTP_PASS)\n    server.send_message(msg)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Port 2525 exists specifically for hosting environments that block the standard SMTP ports, which resolves the AWS, DigitalOcean and Google Cloud blocking problem described earlier in this guide. Nothing about how you build the <code>EmailMessage<\/code> changes \u2014 only the host, port and credentials. Every account includes 5,000 free emails per month, which is enough to validate the full setup end to end before any spend. Full configuration details are on the <a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay page<\/a>, and <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">pricing<\/a> has no monthly minimum.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you are still weighing free options, our analysis of <a href=\"https:\/\/photonconsole.com\/blog\/free-smtp-servers\/\">free SMTP servers<\/a> covers where each one breaks down under production load.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Platform-Specific Notes<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Django<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Django&#8217;s <code>EMAIL_BACKEND<\/code> setting wraps smtplib internally \u2014 set <code>EMAIL_HOST<\/code>, <code>EMAIL_PORT<\/code>, <code>EMAIL_HOST_USER<\/code>, <code>EMAIL_HOST_PASSWORD<\/code> and <code>EMAIL_USE_TLS<\/code> in settings rather than calling smtplib directly, and Django handles connection management for you.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Flask and FastAPI<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Neither framework includes a mail layer. Call smtplib directly for low-volume sending, or move to a background task queue once request-blocking becomes a problem. FastAPI&#8217;s async request handlers should never call blocking smtplib directly inside an <code>async def<\/code> route \u2014 use a thread pool executor or switch to an HTTP email API with an async client.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">AWS Lambda and Serverless Functions<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A cold-started function paying the full TCP and TLS handshake cost on every invocation is expensive and slow. An HTTP email API avoids the persistent-connection problem entirely and is usually the better fit here.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Celery and Background Workers<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each worker process that opens its own SMTP connection counts against your relay&#8217;s concurrent connection limit. Keep worker concurrency aligned with your plan&#8217;s connection allowance, and let each task open and close its own short-lived connection rather than sharing one across processes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips for Reliable Python Email<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Always call <code>quit()<\/code> or use a context manager.<\/strong> An unclosed connection holds a socket open on both ends until it times out.<\/li>\n\n\n\n<li><strong>Set a real Reply-To address.<\/strong> A monitored reply address improves both user trust and engagement signals with mailbox providers.<\/li>\n\n\n\n<li><strong>Send a plain-text alternative alongside HTML.<\/strong> Use <code>add_alternative()<\/code> rather than HTML-only bodies, which score worse with spam filters.<\/li>\n\n\n\n<li><strong>Verify your DNS records after any change.<\/strong> <a href=\"https:\/\/mxtoolbox.com\/\" target=\"_blank\" rel=\"noopener\">MXToolbox<\/a> confirms SPF, DKIM and blocklist status in one lookup.<\/li>\n\n\n\n<li><strong>Test rendering before launch.<\/strong> <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> scores a real send and flags authentication problems before real users see them.<\/li>\n\n\n\n<li><strong>Never log full exception objects that might contain credentials.<\/strong> Log the exception type and SMTP code, not the raw auth payload.<\/li>\n\n\n\n<li><strong>Separate transactional and bulk sending.<\/strong> A batch job that gets rate-limited should never be able to delay a password reset email.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Issues You May Hit Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-authentication-error\/\">SMTP authentication errors<\/a> when credentials are rejected despite being correct<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-not-working\/\">SMTP not working<\/a> across the ten most common failure modes<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/why-emails-go-to-spam-in-gmail\/\">Emails landing in Gmail spam<\/a> despite a successful send<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/emails-sent-but-not-delivered\/\">Emails sent but not delivered<\/a> when the SMTP response says success<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP configuration<\/a> reference for host, port and encryption settings<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Is smtplib still the recommended way to send email in Python?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, for direct SMTP sending. It is part of the standard library, actively maintained, and documented at <a href=\"https:\/\/docs.python.org\/3\/library\/smtplib.html\" target=\"_blank\" rel=\"noopener\">docs.python.org<\/a>. Most third-party Python mail libraries are thin wrappers around it.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use SMTP() with starttls() or SMTP_SSL()?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Use <code>SMTP()<\/code> with <code>.starttls()<\/code> on port 587 for most cases. Use <code>SMTP_SSL()<\/code> on port 465 only if your provider requires the connection to be encrypted from the first byte. Never mix the two patterns on the same port.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does send_message() succeed but no email arrives?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">A successful call means the relay accepted the message for delivery, not that it reached the inbox. It can still bounce or be filtered downstream. Check delivery logs or bounce webhooks for the actual outcome.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Can smtplib send attachments?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. Use <code>EmailMessage.add_attachment()<\/code> with the file&#8217;s bytes, maintype and subtype. The <code>email<\/code> package handles MIME encoding automatically.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I send email asynchronously in Python?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib itself is synchronous and has no native async API. For an <code>asyncio<\/code> application, either run smtplib in a thread pool executor, use a third-party async SMTP library, or switch to an HTTP email API with an async HTTP client.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need SPF and DKIM if I use a relay?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. The relay delivers the message, but authentication records prove your domain authorised the send. Without them, delivery is far less reliable regardless of which relay you use.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What is the difference between smtplib and an email API?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib speaks the SMTP protocol directly over a socket connection. An email API sends the same message over an HTTP POST request instead. Both can reach the same delivery infrastructure \u2014 PhotonConsole supports either \u2014 so the right choice depends on whether your application can hold an open connection or fits better with a stateless HTTP call.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">smtplib does exactly what it was built to do: implement the SMTP protocol correctly. Most of what looks like a library problem in production is actually a connection pattern problem \u2014 the wrong port and security method paired together, no timeout on a blocking call, a fresh connection opened for every message, or a domain with no authentication records.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Fix those four things and direct smtplib sending is reliable well past the point most applications need it to be. The remaining decision is architectural, not a smtplib limitation: once your application moves to a serverless runtime or needs to send at volume that makes persistent SMTP connections impractical, an HTTP email API becomes the better fit, without changing what mail server sits behind either approach.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If your emails are being blocked by port restrictions, throttled by a mailbox provider, or filtered because your sending domain has no reputation, neither smtplib nor an API call will fix that on its own. A dedicated <a href=\"https:\/\/www.photonconsole.com\/relay.php\">transactional email solution<\/a> handles authentication, delivery routing and reputation for you, with the same smtplib code shown throughout this guide.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-configuration\/\">SMTP Configuration: Complete Setup Reference<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/email-api-integration\/\">Email API Integration Guide<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-retry-logic-explained-for-transactional-email-systems\/\">SMTP Retry Logic for Transactional Email Systems<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-response-codes-explained\/\">SMTP Response Codes Explained<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP Connection Timeout: Causes and Fixes<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Your Python emails work locally, then fail in production. Fix smtplib&#8217;s ports, timeouts and retries, or see when an HTTP email API fits better.<\/p>\n","protected":false},"author":1,"featured_media":365,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[31,312],"tags":[507,510,506,508,509],"class_list":["post-364","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-smpt-relay-service","category-email-engineering-guide","tag-python-send-email-smtp","tag-python-send-email-with-attachment","tag-python-smtplib-timeout","tag-smtplib-production-setup","tag-smtplib-vs-email-api"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/364","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=364"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/364\/revisions"}],"predecessor-version":[{"id":368,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/364\/revisions\/368"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/365"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=364"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=364"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=364"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}