11import asyncio as aio
22from email .message import EmailMessage
33from email .utils import make_msgid , formataddr
4+ import mimetypes
5+ from pathlib import Path
46import smtplib
7+ import time
58import warnings
69
10+ from matej .collections import ensure_iterable
11+
712try :
813 import aiosmtplib
914except 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