How-to
Automating Reporting WorkflowsDeep dive

Email a Download Link Instead of an Excel Attachment

Send large or sensitive Excel reports as an expiring download link instead of an attachment: decide when a link is better, upload the workbook to private S3 storage, create a presigned URL that downloads with the right file name and stops working after a set time, write an email that states the expiry, and check the link before sending.

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

Bash
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).

Python
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.

Attach the workbook or send a link Small, non-sensitive reports that recipients want to keep can be attached. Large files, files with personal or confidential data, and reports that may be corrected after sending are better sent as an expiring link. What kind of report is it? small, routine attach it under ~10 MB encoded large send a link email stays small sensitive expiring link no copies in inboxes

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:

Python
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.

A presigned URL embeds a signature that lets whoever holds it perform one operation — here, download one object — until it expires:

Python
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.

From report job to download The job uploads the workbook to a private bucket and creates a presigned link valid for seven days. It emails the link. When the recipient clicks it, S3 checks the signature and expiry and returns the file with its proper file name. Report job S3 Recipient 1. upload (private) 2. presign, 7 days 3. email with link 4. GET signed URL 5. file, if not expired

State what the link is, how big the file is and when the link expires, and give a plain-text alternative:

Python
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.

Size of the email, link versus attachment For a 15.7 MB workbook, the email with a download link is about two kilobytes, while the email with the workbook attached is about 21.2 MB because base64 encoding adds a third. Workbook on disk 15.7 MB As attachment 21.2 MB As link ≈2 KB

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.

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:

Python
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.

More on email delivery

More on email delivery →