359 lines
13 KiB
Python
359 lines
13 KiB
Python
import mimetypes
|
|
from email import encoders
|
|
from email import message_from_bytes
|
|
from email.header import Header
|
|
from email.mime.base import MIMEBase
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.utils import formataddr, formatdate
|
|
from smtplib import SMTP, SMTP_SSL, SMTPServerDisconnected
|
|
|
|
import mistune
|
|
from bs4 import BeautifulSoup
|
|
from os.path import basename
|
|
|
|
class SMTPMailer:
|
|
"""
|
|
Wrapper around `smtplib.SMTP` class, for managing a SMTP client.
|
|
|
|
Parameters
|
|
----------
|
|
host : str
|
|
The hostname of the SMTP server to connect to.
|
|
port : int, optional
|
|
The port number of the SMTP server to connect to.
|
|
user : str, optional
|
|
The username to be used for authentication to the SMTP server.
|
|
password : str, optional
|
|
The password to be used for authentication to the SMTP server.
|
|
ssl : bool, optional
|
|
Whether to use SSL for the SMTP connection.
|
|
tls : bool, optional
|
|
Whether to use TLS // `starttls` for the SMTP connection.
|
|
keyfile : str, optional
|
|
File containing the SSL private key.
|
|
certfile : str, optional
|
|
File containing the SSL certificate in PEM format.
|
|
context: `ssl.SSLContext` object
|
|
The SSL context to be used in the SSL connection.
|
|
|
|
Attributes
|
|
----------
|
|
client: `smtplib.SMTP` object
|
|
The SMTP client that'd be used to send emails.
|
|
connected: bool
|
|
Whether there is an active SMTP connection.
|
|
host : str
|
|
The hostname of the SMTP server to connect to.
|
|
port : int
|
|
The port number of the SMTP server to connect to.
|
|
user : str
|
|
The username to be used for authentication to the SMTP server.
|
|
password : str
|
|
The password to be used for authentication to the SMTP server.
|
|
ssl : bool
|
|
Whether to use SSL for the SMTP connection.
|
|
tls : bool
|
|
Whether to use TLS // `starttls` for the SMTP connection.
|
|
"""
|
|
|
|
def __init__(self, host, port=None, user=None, password=None, ssl=False, tls=False, **kwargs):
|
|
self.host = host
|
|
self.ssl = ssl
|
|
self.tls = tls
|
|
if ssl:
|
|
self.port = port or 465
|
|
elif tls:
|
|
self.port = port or 587
|
|
else:
|
|
self.port = port or 25
|
|
if kwargs is not None:
|
|
self.__ssloptions = dict()
|
|
for key in ['keyfile', 'certfile', 'context']:
|
|
self.__ssloptions[key] = kwargs.get(key, None)
|
|
self.user = user
|
|
self.password = password
|
|
self.connected = False
|
|
self.client = None
|
|
|
|
def connect(self):
|
|
"""
|
|
Create the SMTP connection.
|
|
"""
|
|
self.client = SMTP(self.host, self.port) if not self.ssl else SMTP_SSL(self.host, self.port,
|
|
**self.__ssloptions)
|
|
self.client.ehlo()
|
|
if self.tls:
|
|
self.client.starttls(**self.__ssloptions)
|
|
self.client.ehlo()
|
|
if self.user and self.password:
|
|
self.client.login(self.user, self.password)
|
|
self.connected = True
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def close(self):
|
|
"""
|
|
Close the SMTP connection and `quit` the `self.client` object.
|
|
"""
|
|
if self.connected:
|
|
try:
|
|
self.client.quit()
|
|
except SMTPServerDisconnected:
|
|
pass
|
|
self.connected = False
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.close()
|
|
return False
|
|
|
|
def __del__(self):
|
|
self.close()
|
|
|
|
def send(self, message, sender=None, receivers=None):
|
|
"""
|
|
Send an email through this SMTP client.
|
|
|
|
Parameters
|
|
----------
|
|
message : `drymail.Message` object
|
|
The message to be sent.
|
|
sender : str, optional
|
|
The email address of the sender.
|
|
receivers : list of str, optional
|
|
The email addresses of the receivers // recipients.
|
|
"""
|
|
if not message.prepared:
|
|
message.prepare()
|
|
if not self.connected:
|
|
self.connect()
|
|
self.client.send_message(message.message, from_addr=sender, to_addrs=receivers)
|
|
|
|
|
|
def stringify_address(address):
|
|
"""
|
|
Converts an address into a string in the `"John Doe" <john@example.com>"` format, which can be directly used in the
|
|
headers of an email.
|
|
|
|
Parameters
|
|
----------
|
|
address : str or (str, str)
|
|
An address. Can be either the email address or a tuple of the name
|
|
and the email address.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Address as a single string, in the `"John Doe" <john@example.com>"` format. Returns
|
|
`address` unchanged if it's a single string.
|
|
"""
|
|
address = ('', address) if isinstance(address, str) else address
|
|
return formataddr((str(Header(address[0], 'utf-8')), address[1]))
|
|
|
|
|
|
def stringify_addresses(addresses):
|
|
"""
|
|
Converts a list of addresses into a string in the
|
|
`"John Doe" <john@example.com>, "Jane" <jane@example.com>"` format,
|
|
which can be directly used in the headers of an email.
|
|
|
|
Parameters
|
|
----------
|
|
addresses : (str or (str, str)) or list of (str or (str, str))
|
|
A single address or a list of addresses which is to be converted into a single string. Each element can be
|
|
either an email address or a tuple of a name and an email address.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The address(es) as a single string which can be directly used in the headers of an email.
|
|
"""
|
|
if isinstance(addresses, list):
|
|
addresses = [stringify_address(address) for address in addresses]
|
|
return ', '.join(addresses)
|
|
else:
|
|
return stringify_address(addresses)
|
|
|
|
|
|
class Message:
|
|
"""
|
|
Class representing an email message.
|
|
|
|
Parameters
|
|
----------
|
|
sender : str or (str, str)
|
|
The address of the sender. Can be either the email address or a tuple of the name and the email address.
|
|
receivers : list of (str or (str, str))
|
|
The list of receivers // recipients. Each element can be either an email address or a tuple of a name and an
|
|
email address.
|
|
subject : str, optional
|
|
The subject of the email
|
|
authors : list of (str or (str, str)), optional
|
|
The list of authors, to be mentioned in the `Authors` header. Each
|
|
element can be either an email address or a tuple of a name and an email address.
|
|
cc : list of (str or (str, str)), optional
|
|
The list of addresses to CC to. Each element can be either an email
|
|
address or a tuple of a name and an email address.
|
|
bcc : list of (str or (str, str)), optional
|
|
The list of addresses to BCC to. Each element can be either an email address or a tuple of a name and an email
|
|
address.
|
|
reply_to : list of (str or (str, str)), optional
|
|
The list of addresses to mention in the `Reply-To` header. Each element can be either an email address or a
|
|
tuple of a name and an email address.
|
|
headers : dict, optional
|
|
Custom headers as key-value pairs, to be injected into the email.
|
|
text: str, optional
|
|
The body of the message, as plaintext. At least one among `text` and `html`
|
|
must be provided.
|
|
html: str, optional
|
|
The body of the message, as HTML. At least one among `text` and `html`
|
|
must be provided.
|
|
prepared_message: bytes, optional
|
|
A prepared email as bytes. If this is provided, all the other optional parameters will be ignored.
|
|
|
|
Attributes
|
|
----------
|
|
message: `email.message.Message` object or `email.mime.multipart.MIMEMultipart` object
|
|
The prepared message object.
|
|
prepared: bool
|
|
Whether the message is prepared, in other words whether `self.message` is available and proper.
|
|
sender : str or (str, str)
|
|
The address of the sender. Can be either the email address or a tuple of the name and the email address.
|
|
receivers : list of (str or (str, str))
|
|
The list of receivers // recipients. Each element can be either an email address or a tuple of a name and an
|
|
email address.
|
|
subject : str
|
|
The subject of the email
|
|
authors : list of (str or (str, str))
|
|
The list of authors, to be mentioned in the `Authors` header. Each element can be either an email address or a
|
|
tuple of a name and an email address.
|
|
cc : list of (str or (str, str))
|
|
The list of addresses to CC to. Each element can be either an email address or a tuple of a name and an email
|
|
address.
|
|
bcc : list of (str or (str, str))
|
|
The list of addresses to BCC to. Each element can be either an email address or a tuple of a name and an email
|
|
address.
|
|
reply_to : list of (str or (str, str))
|
|
The list of addresses to mention in the `Reply-To` header. Each element can be either an email address or a
|
|
tuple of a name and an email address.
|
|
headers : dict
|
|
Custom headers as key-value pairs, to be injected into the email.
|
|
text: str
|
|
The body of the message, as plaintext.
|
|
html: str
|
|
The body of the message, as HTML.
|
|
prepared_message: bytes
|
|
A prepared email as bytes.
|
|
"""
|
|
|
|
def __init__(self, sender, receivers, subject=None, authors=None, cc=None, bcc=None, reply_to=None, headers=None,
|
|
text=None, html=None, prepared_message=None):
|
|
self.subject = subject or ''
|
|
self.sender = sender
|
|
self.receivers = receivers
|
|
self.authors = authors
|
|
self.cc = cc
|
|
self.bcc = bcc
|
|
self.headers = headers
|
|
self.reply_to = reply_to
|
|
self.text = text or ''
|
|
self.html = html or ''
|
|
self.__attachments = []
|
|
self.__attachments_data = []
|
|
self.prepared_message = prepared_message
|
|
self.prepared = False
|
|
self.message = MIMEMultipart('mixed')
|
|
|
|
def __str__(self):
|
|
if not self.prepared:
|
|
self.prepare()
|
|
return self.message.as_string()
|
|
|
|
@property
|
|
def attachments(self):
|
|
"""
|
|
All the attachments attached to the message.
|
|
|
|
Returns
|
|
-------
|
|
list of str
|
|
The filenames of the attachments attached.
|
|
"""
|
|
return self.__attachments
|
|
|
|
def attach(self, filename, data=None, mimetype=None):
|
|
"""
|
|
Add a file as attachment to the email.
|
|
|
|
Parameters
|
|
----------
|
|
filename : str
|
|
If data is provoded:
|
|
The filename of the file to be attached.
|
|
If data not provoded:
|
|
The full name (path + filename) of the
|
|
file to be attached.
|
|
data: bytes, optional
|
|
The raw content of the file to be attached.
|
|
mimetype : str, optional
|
|
The MIMEType of the file to be attached.
|
|
"""
|
|
if self.prepared_message:
|
|
return
|
|
|
|
filename_only = basename(filename)
|
|
if not mimetype:
|
|
mimetype, encoding = mimetypes.guess_type(filename)
|
|
if mimetype is None or encoding is not None:
|
|
mimetype = 'application/octet-stream'
|
|
if data:
|
|
maintype, subtype = mimetype.split('/', 1)
|
|
attachment = MIMEBase(maintype, subtype)
|
|
attachment.set_payload(data)
|
|
encoders.encode_base64(attachment)
|
|
attachment.add_header('Content-Disposition', 'attachment', filename=filename_only)
|
|
self.message.attach(attachment)
|
|
else:
|
|
self.__attachments_data.append([filename, mimetype])
|
|
if filename_only not in self.__attachments:
|
|
self.__attachments.append(filename_only)
|
|
|
|
def prepare(self):
|
|
"""
|
|
Prepare the `self.message` object.
|
|
"""
|
|
if self.prepared_message:
|
|
self.message = message_from_bytes(self.prepared_message)
|
|
self.prepared = True
|
|
return
|
|
|
|
self.text = self.text or BeautifulSoup(self.html, 'html.parser').get_text(strip=True)
|
|
self.html = self.html or mistune.markdown(self.text)
|
|
|
|
self.message['Sender'] = stringify_address(self.sender)
|
|
self.message['From'] = stringify_addresses(self.authors) if self.authors else stringify_address(self.sender)
|
|
self.message['To'] = stringify_addresses(self.receivers)
|
|
self.message['Subject'] = self.subject
|
|
self.message["Date"] = formatdate(localtime=True)
|
|
if self.cc:
|
|
self.message['CC'] = stringify_addresses(self.cc)
|
|
if self.bcc:
|
|
self.message['BCC'] = stringify_addresses(self.bcc)
|
|
if self.reply_to:
|
|
self.message['Reply-To'] = stringify_addresses(self.reply_to)
|
|
if self.headers:
|
|
for key, value in self.headers.items():
|
|
self.message[key] = value
|
|
|
|
body = MIMEMultipart('alternative')
|
|
plaintext_part = MIMEText(self.text, 'plain')
|
|
html_part = MIMEText(self.html, 'html')
|
|
body.attach(plaintext_part)
|
|
body.attach(html_part)
|
|
self.message.attach(body)
|
|
if self.__attachments_data:
|
|
for attachment_data in self.__attachments_data:
|
|
with open(attachment_data[0], 'rb') as a_file:
|
|
self.attach(filename=basename(attachment_data[0]), data=a_file.read(), mimetype=attachment_data[1])
|
|
self.prepared = True
|