Skip to content

Commit a1a737c

Browse files
committed
Attachments and rate limiting
1 parent 585797b commit a1a737c

2 files changed

Lines changed: 65 additions & 19 deletions

File tree

scripts/send_email.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
# USAGE: python send_email.py RECIPIENT_EMAIL [RECEIPIENT_EMAIL ...] -f BODY_FILE [OPTIONS]
2+
# USAGE: python send_email.py RECIPIENT_EMAIL [RECIPIENT_EMAIL ...] -f BODY_FILE [OPTIONS]
33
# Keep in mind that many SMTP providers require app-specific passwords, 2FA, or other security measures.
44
# For Gmail specifically, you need to:
55
# - enable 2FA;
@@ -11,12 +11,13 @@
1111
import os
1212
import re
1313

14+
# Install matej libraries (tested with 0.12.3)
1415
from matej.argparse import ArgParser, StrArg
1516
from matej.web.email import send_email
1617

1718

1819
class EmailArg(StrArg):
19-
EMAIL_RE = re.compile(r'[\w\.-]+@[\w\.-]+\.\w+')
20+
EMAIL_RE = re.compile(r'[\w\.-]+@[\w\.-]+\.[\w-]+')
2021

2122
def _type(self, s):
2223
s = super()._type(s)
@@ -32,6 +33,7 @@ def parse_cli_args():
3233
ap = ArgParser(description="Send an email via SMTP.")
3334
ap.add_arg(EmailArg('emails', help="Addresses of the recipients", nargs='*'))
3435
ap.add_path_arg('-f', '--file', help="Path to file containing the email body", required=True)
36+
ap.add_path_arg('-a', '--attachment', '--attachments', help="Path(s) to file(s) to attach", nargs='+')
3537
ap.add_str_arg('-s', '--subject', help="Email subject")
3638
ap.add_str_arg('-u', '--user', help="SMTP login username")
3739
ap.add_str_arg('-p', '--password', help="SMTP password (for increased security, set it in the environment variable SMTP_PASSWORD or leave empty to get prompted securely)")
@@ -42,9 +44,10 @@ def parse_cli_args():
4244
ap.add_str_arg('-n', '--from-name', help="Sender display name")
4345
ap.add_str_arg('-F', '--from-full', help="Override 'From' field with custom value")
4446
ap.add_arg(EmailArg('-r', '--reply-to', help="Reply-To email address"))
45-
ap.add_arg(EmailArg('-c', '--cc', help="CC emails (will be added on each email if sending individually)", nargs='*'))
46-
ap.add_arg(EmailArg('-b', '--bcc', help="BCC emails (will be added on each email if sending individually)", nargs='*'))
47+
ap.add_arg(EmailArg('-c', '--cc', help="CC emails (will be added on each email if sending individually)", nargs='+'))
48+
ap.add_arg(EmailArg('-b', '--bcc', help="BCC emails (will be added on each email if sending individually)", nargs='+'))
4749
ap.add_bool_arg('-i', '--send-individually', help="Send emails to each recipient separately", default=True)
50+
ap.add_number_arg('-l', '--limit', '--rate-limit', help="Rate limit (in emails per second)", nargs=1, default=None, min=0)
4851
return ap.parse_args()
4952

5053

@@ -80,6 +83,10 @@ def detect_html(content):
8083
def main():
8184
args = parse_cli_args()
8285

86+
# Validate recipients
87+
if not args.emails and not args.cc and not args.bcc:
88+
raise ValueError("At least one recipient email address is required (To, CC, or BCC).")
89+
8390
# SMTP username prompt (with repeat on empty)
8491
while not args.user:
8592
args.user = input("SMTP username (email): ").strip()
@@ -106,6 +113,7 @@ def main():
106113
plain_text = raw
107114
html = None
108115

116+
# Send the email
109117
send_email(
110118
smtp_server=args.server,
111119
smtp_port=args.port,
@@ -122,7 +130,9 @@ def main():
122130
subject=args.subject,
123131
plain_text=plain_text,
124132
html_content=html,
133+
attachments=args.attachment,
125134
send_individually=args.send_individually,
135+
rate_limit=args.limit,
126136
)
127137

128138

src/matej/web/email.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import asyncio as aio
22
from email.message import EmailMessage
33
from email.utils import make_msgid, formataddr
4+
import mimetypes
5+
from pathlib import Path
46
import smtplib
7+
import time
58
import warnings
69

10+
from matej.collections import ensure_iterable
11+
712
try:
813
import aiosmtplib
914
except ImportError:
1015
aiosmtplib = None
1116

1217

13-
def _build(subject, plain_text, from_name, from_email, from_full=None, to_addrs=None, cc_addrs=None, reply_to=None, html_content=None):
18+
def _build(subject, plain_text, from_name, from_email, from_full=None, to_addrs=None, cc_addrs=None, reply_to=None, html_content=None, attachments=None):
1419
msg = EmailMessage()
1520
msg['Subject'] = subject
1621
msg['From'] = from_full if from_full else formataddr((from_name, from_email))
@@ -21,10 +26,28 @@ def _build(subject, plain_text, from_name, from_email, from_full=None, to_addrs=
2126
msg['Reply-To'] = reply_to
2227
msg['Message-ID'] = make_msgid()
2328
msg['X-Priority'] = '3'
24-
msg['X-Mailer'] = 'Python EmailSender'
29+
msg['X-Mailer'] = 'PyMailer'
30+
if from_email:
31+
if 'gmail' in from_email.lower():
32+
msg['XMailer'] = 'Gmail' # Maybe less chance of spam filters
33+
elif 'outlook' in from_email.lower():
34+
msg['XMailer'] = 'Outlook' # if we use these headers?
2535
msg.set_content(plain_text)
2636
if html_content:
2737
msg.add_alternative(html_content, subtype='html')
38+
39+
if attachments:
40+
for attachment in ensure_iterable(attachments):
41+
attachment = Path(attachment)
42+
if not attachment.is_file():
43+
raise ValueError(f"Attachment not found or is not a file: {attachment}")
44+
content_type, _ = mimetypes.guess_type(attachment)
45+
if content_type is None:
46+
content_type = 'application/octet-stream'
47+
main_type, sub_type = content_type.split('/', 1)
48+
with attachment.open('rb') as f:
49+
msg.add_attachment(f.read(), maintype=main_type, subtype=sub_type, filename=attachment.name)
50+
2851
return msg
2952

3053

@@ -57,7 +80,7 @@ def _infer_email_address(user, server):
5780
return f'{user}@{domain}'
5881

5982

60-
def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=None, bcc_emails=None, reply_to=None, subject='', plain_text='', html_content=None, smtp_port=465, smtp_protocol=None, from_name='', from_email='', from_full=None, send_individually=False, asynchronous=True):
83+
def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=None, bcc_emails=None, reply_to=None, subject='', plain_text='', html_content=None, attachments=None, smtp_port=465, smtp_protocol=None, from_name='', from_email='', from_full=None, send_individually=False, asynchronous=True, rate_limit=None):
6184
"""
6285
Send an email with support for To, CC, BCC, and optional individual sending.
6386
@@ -69,11 +92,11 @@ def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=
6992
Username for SMTP authentication (usually the sender's email).
7093
smtp_password : str
7194
Password or app-specific password for SMTP authentication.
72-
to_emails : List[str], optional
95+
to_emails : Collection[str], optional
7396
List of recipient email addresses.
74-
cc_emails : List[str], optional
97+
cc_emails : Collection[str], optional
7598
List of CC (carbon copy) email addresses.
76-
bcc_emails : List[str], optional
99+
bcc_emails : Collection[str], optional
77100
List of BCC (blind carbon copy) email addresses.
78101
reply_to : str, optional
79102
Email address for the Reply-To header.
@@ -83,6 +106,8 @@ def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=
83106
Plain-text version of the email body.
84107
html_content : str, optional
85108
HTML version of the email body. If provided, the email will be sent as multipart/alternative.
109+
attachments : Collection[str], optional
110+
List of file paths to attach to the email.
86111
smtp_port : int, default=465
87112
Port number for SMTP (typically 465 for SSL, 587 for STARTTLS).
88113
smtp_protocol : str, optional
@@ -94,16 +119,16 @@ def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=
94119
from_full : str, optional
95120
Override the "From" field with a custom value.
96121
send_individually : bool, default=False
97-
If True, send one email per recipient (To + CC + BCC each get a separate message).
122+
If `True`, send one email per recipient (To + CC + BCC each get a separate message).
98123
asynchronous : bool, default=True
99-
Use aiosmtplib for asnychronous sending.
124+
Send emails asynchronously.
125+
rate_limit : int, optional
126+
Number of emails to send per second. If `None` or `0`, no rate limiting is applied.
100127
"""
101128
to_emails = to_emails or []
102129
cc_emails = cc_emails or []
103130
bcc_emails = bcc_emails or []
104131

105-
all_recipients = list(set(to_emails + cc_emails + bcc_emails))
106-
107132
# Determine SMTP protocol if not provided
108133
if smtp_protocol is None:
109134
smtp_protocol = 'ssl' if smtp_port == 465 else 'starttls' if smtp_port == 587 else ''
@@ -129,19 +154,30 @@ def send_email(smtp_server, smtp_user, smtp_password, to_emails=None, cc_emails=
129154
# Cc list: all CC recipients
130155
cc_list = cc_emails if cc_emails else []
131156
# Build email message with one TO, full CC, no BCC in headers
132-
msg = _build(subject, plain_text, from_name, from_email, from_full, to_list, cc_list, reply_to, html_content)
157+
msg = _build(subject, plain_text, from_name, from_email, from_full, to_list, cc_list, reply_to, html_content, attachments)
133158
# Recipients for SMTP envelope include TO, CC, and BCC
134159
smtp_recipients = list(set(to_list + cc_list + bcc_emails))
135160
msgs.append((msg, smtp_recipients))
136161
else:
137-
msg = _build(subject, plain_text, from_name, from_email, from_full, to_emails, cc_emails, reply_to, html_content)
138-
msgs = [(msg, all_recipients)]
162+
msg = _build(subject, plain_text, from_name, from_email, from_full, to_emails, cc_emails, reply_to, html_content, attachments)
163+
msgs = [(msg, list(set(to_emails + cc_emails + bcc_emails)))]
139164

140165
if asynchronous:
141166
async def _send_all():
142-
tasks = [_send_async(recipients, msg, smtp_server, smtp_port, smtp_protocol, smtp_user, smtp_password) for msg, recipients in msgs]
167+
semaphore = aio.Semaphore(1)
168+
169+
async def limited_send(i, msg, recipients):
170+
async with semaphore:
171+
if rate_limit and i > 0:
172+
await aio.sleep(1 / rate_limit)
173+
await _send_async(recipients, msg, smtp_server, smtp_port, smtp_protocol, smtp_user, smtp_password)
174+
175+
tasks = [aio.create_task(limited_send(i, msg, recipients)) for i, (msg, recipients) in enumerate(msgs)]
143176
await aio.gather(*tasks)
177+
144178
return aio.run(_send_all())
145179

146-
for msg, recipients in msgs:
180+
for i, (msg, recipients) in enumerate(msgs):
181+
if rate_limit and i > 0:
182+
time.sleep(1 / rate_limit)
147183
_send(recipients, msg, smtp_server, smtp_port, smtp_protocol, smtp_user, smtp_password)

0 commit comments

Comments
 (0)