Email a Download Link Instead of an Excel Attachment
Attaching the workbook is the obvious way to email a report, and for small files it is the right one. It stops working well as reports grow. Attachments are base64-encoded, which makes them about a third bigger in transit, so a 15 MB workbook becomes a 20 MB message that many mail systems refuse. Every recipient gets a full copy that lives on in mailboxes, backups and forwards long after the numbers are out of date — awkward for anything containing salaries or customer data. And a corrected version means sending everything again. A download link avoids all three: the email stays small, the file stays in storage you control, and the link can stop working after a week. This page uploads the workbook to private S3 storage, creates an expiring presigned link that downloads with a sensible file name, writes the email around it and checks the link before anything is sent. It belongs to Emailing Excel Reports with smtplib.
Prerequisites
pip install boto3
You need a private S3 bucket and credentials allowed to s3:PutObject and s3:GetObject on it. The outputs on this page were produced against moto, which emulates S3 locally, with a 15 MB stand-in file for the workbook; the code is unchanged against real S3. Other storage services have the same feature under other names — shared access signatures in Azure Blob Storage, signed URLs in Google Cloud Storage, sharing links in OneDrive and SharePoint (see Share an Excel File in OneDrive with a Link Using Graph).
Step 1 — Decide: attachment or link
from pathlib import Path
report = Path("customer_ledger_2026-09.xlsx")
size = report.stat().st_size
encoded = (size + 2) // 3 * 4 # base64 grows data by a third
print(f"workbook {size / 1e6:.1f} MB -> about {encoded / 1e6:.1f} MB as an attachment")
def use_link(size_bytes, sensitive, limit_mb=10):
return sensitive or size_bytes * 4 / 3 > limit_mb * 1e6
print("send as link:", use_link(size, sensitive=True))
The 15.7 MB workbook would travel as a message of 21 MB — above the limit of many corporate mail systems, where 10 to 25 MB is typical, and recipients' limits matter as much as yours. Size is only one reason. A customer ledger is sensitive, so even a small one is better sent as a link that expires than as a copy in every inbox. Keep attachments for small, non-sensitive reports that people want to keep, such as a one-page summary, and send links for the rest.
Step 2 — Upload to private storage
Upload with a key that includes the period and a random component, so links to different reports and runs never collide:
import secrets
import boto3
s3 = boto3.client("s3", region_name="eu-west-1")
BUCKET = "acme-reports"
key = f"ledgers/2026-09/{secrets.token_hex(8)}/{report.name}"
s3.upload_file(str(report), BUCKET, key, ExtraArgs={
"ContentType": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ServerSideEncryption": "AES256"})
head = s3.head_object(Bucket=BUCKET, Key=key)
print(key.rsplit("/", 2)[0], head["ContentLength"], head["ContentType"][:40])
The bucket itself stays private — no public access, no website hosting. The presigned link in the next step grants access to this one object, for a limited time, without making anything public. upload_file switches to multipart uploads for large files automatically; the content type makes browsers treat the download as a spreadsheet. Uploading in general is covered in Upload an Excel Report to Amazon S3 with boto3. A lifecycle rule on the ledgers/ prefix that deletes objects after, say, 30 days makes sure old reports do not accumulate.
Step 3 — Create an expiring link
A presigned URL embeds a signature that lets whoever holds it perform one operation — here, download one object — until it expires:
from datetime import datetime, timedelta, timezone
from urllib.parse import urlparse, parse_qs
from botocore.config import Config
signer = boto3.client("s3", region_name="eu-west-1", config=Config(signature_version="s3v4"))
EXPIRES = timedelta(days=7)
url = signer.generate_presigned_url("get_object", ExpiresIn=int(EXPIRES.total_seconds()), Params={
"Bucket": BUCKET, "Key": key,
"ResponseContentDisposition": f'attachment; filename="{report.name}"'})
query = parse_qs(urlparse(url).query)
expires_at = datetime.now(timezone.utc) + EXPIRES
print(urlparse(url).netloc, "| expires in", int(query["X-Amz-Expires"][0]) // 86400, "days")
With Signature Version 4 the longest allowed lifetime is seven days (604,800 seconds); for longer access, send a link to a page that issues a fresh URL on demand. The ResponseContentDisposition parameter makes the download arrive as customer_ledger_2026-09.xlsx instead of the long random key. One caveat: a URL signed with temporary credentials — an IAM role on a server or a Lambda function — stops working when those credentials expire, which can be after a few hours whatever ExpiresIn says. Sign with credentials that live at least as long as the link.
Step 4 — Write the email around the link
State what the link is, how big the file is and when the link expires, and give a plain-text alternative:
from email.message import EmailMessage
from html import escape
def link_email(to, url, name, size_bytes, expires_at):
until = expires_at.strftime("%A %d %B %Y")
msg = EmailMessage()
msg["From"], msg["To"], msg["Subject"] = "reports@example.com", to, "Customer ledger — September 2026"
msg.set_content(f"The September customer ledger ({size_bytes / 1e6:.0f} MB) is ready to download:\n\n{url}\n\n"
f"The link works until {until}. Please do not forward it.")
msg.add_alternative(
f"<p>The September customer ledger is ready.</p>"
f'<p><a href="{escape(url)}">Download {escape(name)}</a> ({size_bytes / 1e6:.0f} MB)</p>'
f"<p>The link works until <strong>{until}</strong>. Please do not forward it.</p>", subtype="html")
return msg
msg = link_email("credit.control@example.com", url, report.name, size, expires_at)
attached = EmailMessage()
attached.set_content("Attached.")
attached.add_attachment(report.read_bytes(), maintype="application", subtype="octet-stream", filename=report.name)
print(f"link email: {len(msg.as_bytes()) / 1000:.1f} KB | attachment email: {len(attached.as_bytes()) / 1e6:.1f} MB")
The link email is a couple of kilobytes; the same report attached is a message of 21.2 MB. Escaping the URL in the HTML part matters, because presigned URLs contain & characters. Stating the expiry date avoids the support request a week later, and asking people not to forward is worth saying — anyone holding the link can use it until it expires. Sending the message is unchanged from any other report email; Email an Excel Report with an HTML Summary Body covers the HTML side.
A link also changes what "sending a correction" means. If a figure in the workbook turns out to be wrong, upload the corrected file to the same key and the existing link serves the new version for the rest of its lifetime — nobody needs a second email, and nobody keeps working from the wrong copy because it sits in their inbox. Whether that is desirable depends on the report: for audit-sensitive material, upload the correction under a new key and send a new link with a note explaining the change, so each version stays traceable.
Step 5 — Check the link before sending
A link that does not work is worse than an attachment that is too big. Fetch it once — or just its headers — before the email goes out:
import requests
r = requests.get(url, stream=True, timeout=60)
print(r.status_code, r.headers.get("Content-Disposition"), r.headers.get("Content-Length"))
r.close()
The link returns the file with the intended file name and the full length. Make this a gate: if the check fails, do not send the email. Note that moto does not enforce expiry, so this check cannot test that the link stops working; against real S3 an expired link returns HTTP 403 with "Request has expired", which is worth confirming once by hand with a short ExpiresIn when you first set the process up.
For an audit trail of who downloaded what, enable S3 server access logging or CloudTrail data events on the bucket. Each download of a presigned URL is logged with the requester's IP address and time, which answers the question an attachment never can: whether the report was actually opened, and whether the link was used from somewhere unexpected after being forwarded.
Common pitfalls
- A public bucket. Keep it private; the presigned URL grants the access.
- Links that expire early. URLs signed with temporary role credentials die with them.
- More than seven days. SigV4 links cannot last longer; issue fresh links on demand.
- Random key as the file name. Set
ResponseContentDisposition. - Unescaped URLs in HTML. Escape the
&characters.
Conclusion
When a workbook is large or sensitive, email a link instead of the file: upload it to a private bucket under an unguessable key, create a SigV4 presigned URL of up to seven days with ResponseContentDisposition set, and send a short email that states the size and expiry. Check that the link downloads before sending, sign with credentials that outlive the link, and let a lifecycle rule remove old files.
Frequently asked questions
How do I send a large Excel file by email in Python? Upload it to storage such as S3 and email a presigned download link instead of attaching it; the email stays small and the link can expire.
How long can an S3 presigned URL last? Up to seven days with Signature Version 4, and less if it was signed with temporary credentials that expire sooner.
How do I make a presigned URL download with the right file name? Pass ResponseContentDisposition set to attachment with the file name when generating the URL.
Why is an attachment bigger than the file? Email attachments are base64-encoded, which adds about a third to their size.
Related
- Up one level: Emailing Excel Reports with smtplib — sending reports from Python.
- Zip Large Excel Attachments Before Emailing — when an attachment is still needed.
- Upload an Excel Report to Amazon S3 with boto3 — the upload step.
- Queue and Retry Failed Report Emails — reliable delivery.
More on email delivery
- How-toBuild a Credit Control Dunning Letter Run
- How-toAttach Multiple Excel Files to One Email in Python
- How-toDownload Excel Attachments from an Email Inbox with Python
- How-toEmail an Excel Report with an HTML Summary Body
- How-toEmbed a Chart Image in the Body of a Report Email
- How-toPersonalise Report Emails with Jinja2 Templates