123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582 |
- # -*- coding: utf-8 -*-
- """
- flaskext.mail
- ~~~~~~~~~~~~~
- Flask extension for sending email.
- :copyright: (c) 2010 by Dan Jacob.
- :license: BSD, see LICENSE for more details.
- """
- from __future__ import with_statement
- __version__ = '0.9.1'
- import re
- import blinker
- import smtplib
- import sys
- import time
- import unicodedata
- from email import charset
- from email.encoders import encode_base64
- from email.mime.base import MIMEBase
- from email.mime.multipart import MIMEMultipart
- from email.mime.text import MIMEText
- from email.header import Header
- from email.utils import formatdate, formataddr, make_msgid, parseaddr
- from contextlib import contextmanager
- from flask import current_app
- PY3 = sys.version_info[0] == 3
- PY34 = PY3 and sys.version_info[1] >= 4
- if PY3:
- string_types = str,
- text_type = str
- from email import policy
- message_policy = policy.SMTP
- else:
- string_types = basestring,
- text_type = unicode
- message_policy = None
- charset.add_charset('utf-8', charset.SHORTEST, None, 'utf-8')
- class FlaskMailUnicodeDecodeError(UnicodeDecodeError):
- def __init__(self, obj, *args):
- self.obj = obj
- UnicodeDecodeError.__init__(self, *args)
- def __str__(self):
- original = UnicodeDecodeError.__str__(self)
- return '%s. You passed in %r (%s)' % (original, self.obj, type(self.obj))
- def force_text(s, encoding='utf-8', errors='strict'):
- """
- Similar to smart_text, except that lazy instances are resolved to
- strings, rather than kept as lazy objects.
- If strings_only is True, don't convert (some) non-string-like objects.
- """
- if isinstance(s, text_type):
- return s
- try:
- if not isinstance(s, string_types):
- if PY3:
- if isinstance(s, bytes):
- s = text_type(s, encoding, errors)
- else:
- s = text_type(s)
- elif hasattr(s, '__unicode__'):
- s = s.__unicode__()
- else:
- s = text_type(bytes(s), encoding, errors)
- else:
- s = s.decode(encoding, errors)
- except UnicodeDecodeError as e:
- if not isinstance(s, Exception):
- raise FlaskMailUnicodeDecodeError(s, *e.args)
- else:
- s = ' '.join([force_text(arg, encoding, strings_only,
- errors) for arg in s])
- return s
- def sanitize_subject(subject, encoding='utf-8'):
- try:
- subject.encode('ascii')
- except UnicodeEncodeError:
- try:
- subject = Header(subject, encoding).encode()
- except UnicodeEncodeError:
- subject = Header(subject, 'utf-8').encode()
- return subject
- def sanitize_address(addr, encoding='utf-8'):
- if isinstance(addr, string_types):
- addr = parseaddr(force_text(addr))
- nm, addr = addr
- try:
- nm = Header(nm, encoding).encode()
- except UnicodeEncodeError:
- nm = Header(nm, 'utf-8').encode()
- try:
- addr.encode('ascii')
- except UnicodeEncodeError: # IDN
- if '@' in addr:
- localpart, domain = addr.split('@', 1)
- localpart = str(Header(localpart, encoding))
- domain = domain.encode('idna').decode('ascii')
- addr = '@'.join([localpart, domain])
- else:
- addr = Header(addr, encoding).encode()
- return formataddr((nm, addr))
- def sanitize_addresses(addresses, encoding='utf-8'):
- return map(lambda e: sanitize_address(e, encoding), addresses)
- def _has_newline(line):
- """Used by has_bad_header to check for \\r or \\n"""
- if line and ('\r' in line or '\n' in line):
- return True
- return False
- class Connection(object):
- """Handles connection to host."""
- def __init__(self, mail):
- self.mail = mail
- def __enter__(self):
- if self.mail.suppress:
- self.host = None
- else:
- self.host = self.configure_host()
- self.num_emails = 0
- return self
- def __exit__(self, exc_type, exc_value, tb):
- if self.host:
- self.host.quit()
- def configure_host(self):
- if self.mail.use_ssl:
- host = smtplib.SMTP_SSL(self.mail.server, self.mail.port)
- else:
- host = smtplib.SMTP(self.mail.server, self.mail.port)
- host.set_debuglevel(int(self.mail.debug))
- if self.mail.use_tls:
- host.starttls()
- if self.mail.username and self.mail.password:
- host.login(self.mail.username, self.mail.password)
- return host
- def send(self, message, envelope_from=None):
- """Verifies and sends message.
- :param message: Message instance.
- :param envelope_from: Email address to be used in MAIL FROM command.
- """
- assert message.send_to, "No recipients have been added"
- assert message.sender, (
- "The message does not specify a sender and a default sender "
- "has not been configured")
- if message.has_bad_headers():
- raise BadHeaderError
- if message.date is None:
- message.date = time.time()
- if self.host:
- self.host.sendmail(sanitize_address(envelope_from or message.sender),
- list(sanitize_addresses(message.send_to)),
- message.as_bytes() if PY3 else message.as_string(),
- message.mail_options,
- message.rcpt_options)
- email_dispatched.send(message, app=current_app._get_current_object())
- self.num_emails += 1
- if self.num_emails == self.mail.max_emails:
- self.num_emails = 0
- if self.host:
- self.host.quit()
- self.host = self.configure_host()
- def send_message(self, *args, **kwargs):
- """Shortcut for send(msg).
- Takes same arguments as Message constructor.
- :versionadded: 0.3.5
- """
- self.send(Message(*args, **kwargs))
- class BadHeaderError(Exception):
- pass
- class Attachment(object):
- """Encapsulates file attachment information.
- :versionadded: 0.3.5
- :param filename: filename of attachment
- :param content_type: file mimetype
- :param data: the raw file data
- :param disposition: content-disposition (if any)
- """
- def __init__(self, filename=None, content_type=None, data=None,
- disposition=None, headers=None):
- self.filename = filename
- self.content_type = content_type
- self.data = data
- self.disposition = disposition or 'attachment'
- self.headers = headers or {}
- class Message(object):
- """Encapsulates an email message.
- :param subject: email subject header
- :param recipients: list of email addresses
- :param body: plain text message
- :param html: HTML message
- :param sender: email sender address, or **MAIL_DEFAULT_SENDER** by default
- :param cc: CC list
- :param bcc: BCC list
- :param attachments: list of Attachment instances
- :param reply_to: reply-to address
- :param date: send date
- :param charset: message character set
- :param extra_headers: A dictionary of additional headers for the message
- :param mail_options: A list of ESMTP options to be used in MAIL FROM command
- :param rcpt_options: A list of ESMTP options to be used in RCPT commands
- """
- def __init__(self, subject='',
- recipients=None,
- body=None,
- html=None,
- sender=None,
- cc=None,
- bcc=None,
- attachments=None,
- reply_to=None,
- date=None,
- charset=None,
- extra_headers=None,
- mail_options=None,
- rcpt_options=None):
- sender = sender or current_app.extensions['mail'].default_sender
- if isinstance(sender, tuple):
- sender = "%s <%s>" % sender
- self.recipients = recipients or []
- self.subject = subject
- self.sender = sender
- self.reply_to = reply_to
- self.cc = cc or []
- self.bcc = bcc or []
- self.body = body
- self.html = html
- self.date = date
- self.msgId = make_msgid()
- self.charset = charset
- self.extra_headers = extra_headers
- self.mail_options = mail_options or []
- self.rcpt_options = rcpt_options or []
- self.attachments = attachments or []
- @property
- def send_to(self):
- return set(self.recipients) | set(self.bcc or ()) | set(self.cc or ())
- def _mimetext(self, text, subtype='plain'):
- """Creates a MIMEText object with the given subtype (default: 'plain')
- If the text is unicode, the utf-8 charset is used.
- """
- charset = self.charset or 'utf-8'
- return MIMEText(text, _subtype=subtype, _charset=charset)
- def _message(self):
- """Creates the email"""
- ascii_attachments = current_app.extensions['mail'].ascii_attachments
- encoding = self.charset or 'utf-8'
- attachments = self.attachments or []
- if len(attachments) == 0 and not self.html:
- # No html content and zero attachments means plain text
- msg = self._mimetext(self.body)
- elif len(attachments) > 0 and not self.html:
- # No html and at least one attachment means multipart
- msg = MIMEMultipart()
- msg.attach(self._mimetext(self.body))
- else:
- # Anything else
- msg = MIMEMultipart()
- alternative = MIMEMultipart('alternative')
- alternative.attach(self._mimetext(self.body, 'plain'))
- alternative.attach(self._mimetext(self.html, 'html'))
- msg.attach(alternative)
- if self.subject:
- msg['Subject'] = sanitize_subject(force_text(self.subject), encoding)
- msg['From'] = sanitize_address(self.sender, encoding)
- msg['To'] = ', '.join(list(set(sanitize_addresses(self.recipients, encoding))))
- msg['Date'] = formatdate(self.date, localtime=True)
- # see RFC 5322 section 3.6.4.
- msg['Message-ID'] = self.msgId
- if self.cc:
- msg['Cc'] = ', '.join(list(set(sanitize_addresses(self.cc, encoding))))
- if self.reply_to:
- msg['Reply-To'] = sanitize_address(self.reply_to, encoding)
- if self.extra_headers:
- for k, v in self.extra_headers.items():
- msg[k] = v
- SPACES = re.compile(r'[\s]+', re.UNICODE)
- for attachment in attachments:
- f = MIMEBase(*attachment.content_type.split('/'))
- f.set_payload(attachment.data)
- encode_base64(f)
- filename = attachment.filename
- if filename and ascii_attachments:
- # force filename to ascii
- filename = unicodedata.normalize('NFKD', filename)
- filename = filename.encode('ascii', 'ignore').decode('ascii')
- filename = SPACES.sub(u' ', filename).strip()
- try:
- filename and filename.encode('ascii')
- except UnicodeEncodeError:
- if not PY3:
- filename = filename.encode('utf8')
- filename = ('UTF8', '', filename)
- f.add_header('Content-Disposition',
- attachment.disposition,
- filename=filename)
- for key, value in attachment.headers:
- f.add_header(key, value)
- msg.attach(f)
- if message_policy:
- msg.policy = message_policy
- return msg
- def as_string(self):
- return self._message().as_string()
- def as_bytes(self):
- if PY34:
- return self._message().as_bytes()
- else: # fallback for old Python (3) versions
- return self._message().as_string().encode(self.charset or 'utf-8')
- def __str__(self):
- return self.as_string()
- def __bytes__(self):
- return self.as_bytes()
- def has_bad_headers(self):
- """Checks for bad headers i.e. newlines in subject, sender or recipients.
- RFC5322: Allows multiline CRLF with trailing whitespace (FWS) in headers
- """
- headers = [self.sender, self.reply_to] + self.recipients
- for header in headers:
- if _has_newline(header):
- return True
- if self.subject:
- if _has_newline(self.subject):
- for linenum, line in enumerate(self.subject.split('\r\n')):
- if not line:
- return True
- if linenum > 0 and line[0] not in '\t ':
- return True
- if _has_newline(line):
- return True
- if len(line.strip()) == 0:
- return True
- return False
- def is_bad_headers(self):
- from warnings import warn
- msg = 'is_bad_headers is deprecated, use the new has_bad_headers method instead.'
- warn(DeprecationWarning(msg), stacklevel=1)
- return self.has_bad_headers()
- def send(self, connection):
- """Verifies and sends the message."""
- connection.send(self)
- def add_recipient(self, recipient):
- """Adds another recipient to the message.
- :param recipient: email address of recipient.
- """
- self.recipients.append(recipient)
- def attach(self,
- filename=None,
- content_type=None,
- data=None,
- disposition=None,
- headers=None):
- """Adds an attachment to the message.
- :param filename: filename of attachment
- :param content_type: file mimetype
- :param data: the raw file data
- :param disposition: content-disposition (if any)
- """
- self.attachments.append(
- Attachment(filename, content_type, data, disposition, headers))
- class _MailMixin(object):
- @contextmanager
- def record_messages(self):
- """Records all messages. Use in unit tests for example::
- with mail.record_messages() as outbox:
- response = app.test_client.get("/email-sending-view/")
- assert len(outbox) == 1
- assert outbox[0].subject == "testing"
- You must have blinker installed in order to use this feature.
- :versionadded: 0.4
- """
- if not email_dispatched:
- raise RuntimeError("blinker must be installed")
- outbox = []
- def _record(message, app):
- outbox.append(message)
- email_dispatched.connect(_record)
- try:
- yield outbox
- finally:
- email_dispatched.disconnect(_record)
- def send(self, message):
- """Sends a single message instance. If TESTING is True the message will
- not actually be sent.
- :param message: a Message instance.
- """
- with self.connect() as connection:
- message.send(connection)
- def send_message(self, *args, **kwargs):
- """Shortcut for send(msg).
- Takes same arguments as Message constructor.
- :versionadded: 0.3.5
- """
- self.send(Message(*args, **kwargs))
- def connect(self):
- """Opens a connection to the mail host."""
- app = getattr(self, "app", None) or current_app
- try:
- return Connection(app.extensions['mail'])
- except KeyError:
- raise RuntimeError("The curent application was not configured with Flask-Mail")
- class _Mail(_MailMixin):
- def __init__(self, server, username, password, port, use_tls, use_ssl,
- default_sender, debug, max_emails, suppress,
- ascii_attachments=False):
- self.server = server
- self.username = username
- self.password = password
- self.port = port
- self.use_tls = use_tls
- self.use_ssl = use_ssl
- self.default_sender = default_sender
- self.debug = debug
- self.max_emails = max_emails
- self.suppress = suppress
- self.ascii_attachments = ascii_attachments
- class Mail(_MailMixin):
- """Manages email messaging
- :param app: Flask instance
- """
- def __init__(self, app=None):
- self.app = app
- if app is not None:
- self.state = self.init_app(app)
- else:
- self.state = None
- def init_mail(self, config, debug=False, testing=False):
- return _Mail(
- config.get('MAIL_SERVER', '127.0.0.1'),
- config.get('MAIL_USERNAME'),
- config.get('MAIL_PASSWORD'),
- config.get('MAIL_PORT', 25),
- config.get('MAIL_USE_TLS', False),
- config.get('MAIL_USE_SSL', False),
- config.get('MAIL_DEFAULT_SENDER'),
- int(config.get('MAIL_DEBUG', debug)),
- config.get('MAIL_MAX_EMAILS'),
- config.get('MAIL_SUPPRESS_SEND', testing),
- config.get('MAIL_ASCII_ATTACHMENTS', False)
- )
- def init_app(self, app):
- """Initializes your mail settings from the application settings.
- You can use this if you want to set up your Mail instance
- at configuration time.
- :param app: Flask application instance
- """
- state = self.init_mail(app.config, app.debug, app.testing)
- # register extension with app
- app.extensions = getattr(app, 'extensions', {})
- app.extensions['mail'] = state
- return state
- def __getattr__(self, name):
- return getattr(self.state, name, None)
- signals = blinker.Namespace()
- email_dispatched = signals.signal("email-dispatched", doc="""
- Signal sent when an email is dispatched. This signal will also be sent
- in testing mode, even though the email will not actually be sent.
- """)
|