flask_mail.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskext.mail
  4. ~~~~~~~~~~~~~
  5. Flask extension for sending email.
  6. :copyright: (c) 2010 by Dan Jacob.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. from __future__ import with_statement
  10. __version__ = '0.9.1'
  11. import re
  12. import blinker
  13. import smtplib
  14. import sys
  15. import time
  16. import unicodedata
  17. from email import charset
  18. from email.encoders import encode_base64
  19. from email.mime.base import MIMEBase
  20. from email.mime.multipart import MIMEMultipart
  21. from email.mime.text import MIMEText
  22. from email.header import Header
  23. from email.utils import formatdate, formataddr, make_msgid, parseaddr
  24. from contextlib import contextmanager
  25. from flask import current_app
  26. PY3 = sys.version_info[0] == 3
  27. PY34 = PY3 and sys.version_info[1] >= 4
  28. if PY3:
  29. string_types = str,
  30. text_type = str
  31. from email import policy
  32. message_policy = policy.SMTP
  33. else:
  34. string_types = basestring,
  35. text_type = unicode
  36. message_policy = None
  37. charset.add_charset('utf-8', charset.SHORTEST, None, 'utf-8')
  38. class FlaskMailUnicodeDecodeError(UnicodeDecodeError):
  39. def __init__(self, obj, *args):
  40. self.obj = obj
  41. UnicodeDecodeError.__init__(self, *args)
  42. def __str__(self):
  43. original = UnicodeDecodeError.__str__(self)
  44. return '%s. You passed in %r (%s)' % (original, self.obj, type(self.obj))
  45. def force_text(s, encoding='utf-8', errors='strict'):
  46. """
  47. Similar to smart_text, except that lazy instances are resolved to
  48. strings, rather than kept as lazy objects.
  49. If strings_only is True, don't convert (some) non-string-like objects.
  50. """
  51. if isinstance(s, text_type):
  52. return s
  53. try:
  54. if not isinstance(s, string_types):
  55. if PY3:
  56. if isinstance(s, bytes):
  57. s = text_type(s, encoding, errors)
  58. else:
  59. s = text_type(s)
  60. elif hasattr(s, '__unicode__'):
  61. s = s.__unicode__()
  62. else:
  63. s = text_type(bytes(s), encoding, errors)
  64. else:
  65. s = s.decode(encoding, errors)
  66. except UnicodeDecodeError as e:
  67. if not isinstance(s, Exception):
  68. raise FlaskMailUnicodeDecodeError(s, *e.args)
  69. else:
  70. s = ' '.join([force_text(arg, encoding, strings_only,
  71. errors) for arg in s])
  72. return s
  73. def sanitize_subject(subject, encoding='utf-8'):
  74. try:
  75. subject.encode('ascii')
  76. except UnicodeEncodeError:
  77. try:
  78. subject = Header(subject, encoding).encode()
  79. except UnicodeEncodeError:
  80. subject = Header(subject, 'utf-8').encode()
  81. return subject
  82. def sanitize_address(addr, encoding='utf-8'):
  83. if isinstance(addr, string_types):
  84. addr = parseaddr(force_text(addr))
  85. nm, addr = addr
  86. try:
  87. nm = Header(nm, encoding).encode()
  88. except UnicodeEncodeError:
  89. nm = Header(nm, 'utf-8').encode()
  90. try:
  91. addr.encode('ascii')
  92. except UnicodeEncodeError: # IDN
  93. if '@' in addr:
  94. localpart, domain = addr.split('@', 1)
  95. localpart = str(Header(localpart, encoding))
  96. domain = domain.encode('idna').decode('ascii')
  97. addr = '@'.join([localpart, domain])
  98. else:
  99. addr = Header(addr, encoding).encode()
  100. return formataddr((nm, addr))
  101. def sanitize_addresses(addresses, encoding='utf-8'):
  102. return map(lambda e: sanitize_address(e, encoding), addresses)
  103. def _has_newline(line):
  104. """Used by has_bad_header to check for \\r or \\n"""
  105. if line and ('\r' in line or '\n' in line):
  106. return True
  107. return False
  108. class Connection(object):
  109. """Handles connection to host."""
  110. def __init__(self, mail):
  111. self.mail = mail
  112. def __enter__(self):
  113. if self.mail.suppress:
  114. self.host = None
  115. else:
  116. self.host = self.configure_host()
  117. self.num_emails = 0
  118. return self
  119. def __exit__(self, exc_type, exc_value, tb):
  120. if self.host:
  121. self.host.quit()
  122. def configure_host(self):
  123. if self.mail.use_ssl:
  124. host = smtplib.SMTP_SSL(self.mail.server, self.mail.port)
  125. else:
  126. host = smtplib.SMTP(self.mail.server, self.mail.port)
  127. host.set_debuglevel(int(self.mail.debug))
  128. if self.mail.use_tls:
  129. host.starttls()
  130. if self.mail.username and self.mail.password:
  131. host.login(self.mail.username, self.mail.password)
  132. return host
  133. def send(self, message, envelope_from=None):
  134. """Verifies and sends message.
  135. :param message: Message instance.
  136. :param envelope_from: Email address to be used in MAIL FROM command.
  137. """
  138. assert message.send_to, "No recipients have been added"
  139. assert message.sender, (
  140. "The message does not specify a sender and a default sender "
  141. "has not been configured")
  142. if message.has_bad_headers():
  143. raise BadHeaderError
  144. if message.date is None:
  145. message.date = time.time()
  146. if self.host:
  147. self.host.sendmail(sanitize_address(envelope_from or message.sender),
  148. list(sanitize_addresses(message.send_to)),
  149. message.as_bytes() if PY3 else message.as_string(),
  150. message.mail_options,
  151. message.rcpt_options)
  152. email_dispatched.send(message, app=current_app._get_current_object())
  153. self.num_emails += 1
  154. if self.num_emails == self.mail.max_emails:
  155. self.num_emails = 0
  156. if self.host:
  157. self.host.quit()
  158. self.host = self.configure_host()
  159. def send_message(self, *args, **kwargs):
  160. """Shortcut for send(msg).
  161. Takes same arguments as Message constructor.
  162. :versionadded: 0.3.5
  163. """
  164. self.send(Message(*args, **kwargs))
  165. class BadHeaderError(Exception):
  166. pass
  167. class Attachment(object):
  168. """Encapsulates file attachment information.
  169. :versionadded: 0.3.5
  170. :param filename: filename of attachment
  171. :param content_type: file mimetype
  172. :param data: the raw file data
  173. :param disposition: content-disposition (if any)
  174. """
  175. def __init__(self, filename=None, content_type=None, data=None,
  176. disposition=None, headers=None):
  177. self.filename = filename
  178. self.content_type = content_type
  179. self.data = data
  180. self.disposition = disposition or 'attachment'
  181. self.headers = headers or {}
  182. class Message(object):
  183. """Encapsulates an email message.
  184. :param subject: email subject header
  185. :param recipients: list of email addresses
  186. :param body: plain text message
  187. :param html: HTML message
  188. :param sender: email sender address, or **MAIL_DEFAULT_SENDER** by default
  189. :param cc: CC list
  190. :param bcc: BCC list
  191. :param attachments: list of Attachment instances
  192. :param reply_to: reply-to address
  193. :param date: send date
  194. :param charset: message character set
  195. :param extra_headers: A dictionary of additional headers for the message
  196. :param mail_options: A list of ESMTP options to be used in MAIL FROM command
  197. :param rcpt_options: A list of ESMTP options to be used in RCPT commands
  198. """
  199. def __init__(self, subject='',
  200. recipients=None,
  201. body=None,
  202. html=None,
  203. sender=None,
  204. cc=None,
  205. bcc=None,
  206. attachments=None,
  207. reply_to=None,
  208. date=None,
  209. charset=None,
  210. extra_headers=None,
  211. mail_options=None,
  212. rcpt_options=None):
  213. sender = sender or current_app.extensions['mail'].default_sender
  214. if isinstance(sender, tuple):
  215. sender = "%s <%s>" % sender
  216. self.recipients = recipients or []
  217. self.subject = subject
  218. self.sender = sender
  219. self.reply_to = reply_to
  220. self.cc = cc or []
  221. self.bcc = bcc or []
  222. self.body = body
  223. self.html = html
  224. self.date = date
  225. self.msgId = make_msgid()
  226. self.charset = charset
  227. self.extra_headers = extra_headers
  228. self.mail_options = mail_options or []
  229. self.rcpt_options = rcpt_options or []
  230. self.attachments = attachments or []
  231. @property
  232. def send_to(self):
  233. return set(self.recipients) | set(self.bcc or ()) | set(self.cc or ())
  234. def _mimetext(self, text, subtype='plain'):
  235. """Creates a MIMEText object with the given subtype (default: 'plain')
  236. If the text is unicode, the utf-8 charset is used.
  237. """
  238. charset = self.charset or 'utf-8'
  239. return MIMEText(text, _subtype=subtype, _charset=charset)
  240. def _message(self):
  241. """Creates the email"""
  242. ascii_attachments = current_app.extensions['mail'].ascii_attachments
  243. encoding = self.charset or 'utf-8'
  244. attachments = self.attachments or []
  245. if len(attachments) == 0 and not self.html:
  246. # No html content and zero attachments means plain text
  247. msg = self._mimetext(self.body)
  248. elif len(attachments) > 0 and not self.html:
  249. # No html and at least one attachment means multipart
  250. msg = MIMEMultipart()
  251. msg.attach(self._mimetext(self.body))
  252. else:
  253. # Anything else
  254. msg = MIMEMultipart()
  255. alternative = MIMEMultipart('alternative')
  256. alternative.attach(self._mimetext(self.body, 'plain'))
  257. alternative.attach(self._mimetext(self.html, 'html'))
  258. msg.attach(alternative)
  259. if self.subject:
  260. msg['Subject'] = sanitize_subject(force_text(self.subject), encoding)
  261. msg['From'] = sanitize_address(self.sender, encoding)
  262. msg['To'] = ', '.join(list(set(sanitize_addresses(self.recipients, encoding))))
  263. msg['Date'] = formatdate(self.date, localtime=True)
  264. # see RFC 5322 section 3.6.4.
  265. msg['Message-ID'] = self.msgId
  266. if self.cc:
  267. msg['Cc'] = ', '.join(list(set(sanitize_addresses(self.cc, encoding))))
  268. if self.reply_to:
  269. msg['Reply-To'] = sanitize_address(self.reply_to, encoding)
  270. if self.extra_headers:
  271. for k, v in self.extra_headers.items():
  272. msg[k] = v
  273. SPACES = re.compile(r'[\s]+', re.UNICODE)
  274. for attachment in attachments:
  275. f = MIMEBase(*attachment.content_type.split('/'))
  276. f.set_payload(attachment.data)
  277. encode_base64(f)
  278. filename = attachment.filename
  279. if filename and ascii_attachments:
  280. # force filename to ascii
  281. filename = unicodedata.normalize('NFKD', filename)
  282. filename = filename.encode('ascii', 'ignore').decode('ascii')
  283. filename = SPACES.sub(u' ', filename).strip()
  284. try:
  285. filename and filename.encode('ascii')
  286. except UnicodeEncodeError:
  287. if not PY3:
  288. filename = filename.encode('utf8')
  289. filename = ('UTF8', '', filename)
  290. f.add_header('Content-Disposition',
  291. attachment.disposition,
  292. filename=filename)
  293. for key, value in attachment.headers:
  294. f.add_header(key, value)
  295. msg.attach(f)
  296. if message_policy:
  297. msg.policy = message_policy
  298. return msg
  299. def as_string(self):
  300. return self._message().as_string()
  301. def as_bytes(self):
  302. if PY34:
  303. return self._message().as_bytes()
  304. else: # fallback for old Python (3) versions
  305. return self._message().as_string().encode(self.charset or 'utf-8')
  306. def __str__(self):
  307. return self.as_string()
  308. def __bytes__(self):
  309. return self.as_bytes()
  310. def has_bad_headers(self):
  311. """Checks for bad headers i.e. newlines in subject, sender or recipients.
  312. RFC5322: Allows multiline CRLF with trailing whitespace (FWS) in headers
  313. """
  314. headers = [self.sender, self.reply_to] + self.recipients
  315. for header in headers:
  316. if _has_newline(header):
  317. return True
  318. if self.subject:
  319. if _has_newline(self.subject):
  320. for linenum, line in enumerate(self.subject.split('\r\n')):
  321. if not line:
  322. return True
  323. if linenum > 0 and line[0] not in '\t ':
  324. return True
  325. if _has_newline(line):
  326. return True
  327. if len(line.strip()) == 0:
  328. return True
  329. return False
  330. def is_bad_headers(self):
  331. from warnings import warn
  332. msg = 'is_bad_headers is deprecated, use the new has_bad_headers method instead.'
  333. warn(DeprecationWarning(msg), stacklevel=1)
  334. return self.has_bad_headers()
  335. def send(self, connection):
  336. """Verifies and sends the message."""
  337. connection.send(self)
  338. def add_recipient(self, recipient):
  339. """Adds another recipient to the message.
  340. :param recipient: email address of recipient.
  341. """
  342. self.recipients.append(recipient)
  343. def attach(self,
  344. filename=None,
  345. content_type=None,
  346. data=None,
  347. disposition=None,
  348. headers=None):
  349. """Adds an attachment to the message.
  350. :param filename: filename of attachment
  351. :param content_type: file mimetype
  352. :param data: the raw file data
  353. :param disposition: content-disposition (if any)
  354. """
  355. self.attachments.append(
  356. Attachment(filename, content_type, data, disposition, headers))
  357. class _MailMixin(object):
  358. @contextmanager
  359. def record_messages(self):
  360. """Records all messages. Use in unit tests for example::
  361. with mail.record_messages() as outbox:
  362. response = app.test_client.get("/email-sending-view/")
  363. assert len(outbox) == 1
  364. assert outbox[0].subject == "testing"
  365. You must have blinker installed in order to use this feature.
  366. :versionadded: 0.4
  367. """
  368. if not email_dispatched:
  369. raise RuntimeError("blinker must be installed")
  370. outbox = []
  371. def _record(message, app):
  372. outbox.append(message)
  373. email_dispatched.connect(_record)
  374. try:
  375. yield outbox
  376. finally:
  377. email_dispatched.disconnect(_record)
  378. def send(self, message):
  379. """Sends a single message instance. If TESTING is True the message will
  380. not actually be sent.
  381. :param message: a Message instance.
  382. """
  383. with self.connect() as connection:
  384. message.send(connection)
  385. def send_message(self, *args, **kwargs):
  386. """Shortcut for send(msg).
  387. Takes same arguments as Message constructor.
  388. :versionadded: 0.3.5
  389. """
  390. self.send(Message(*args, **kwargs))
  391. def connect(self):
  392. """Opens a connection to the mail host."""
  393. app = getattr(self, "app", None) or current_app
  394. try:
  395. return Connection(app.extensions['mail'])
  396. except KeyError:
  397. raise RuntimeError("The curent application was not configured with Flask-Mail")
  398. class _Mail(_MailMixin):
  399. def __init__(self, server, username, password, port, use_tls, use_ssl,
  400. default_sender, debug, max_emails, suppress,
  401. ascii_attachments=False):
  402. self.server = server
  403. self.username = username
  404. self.password = password
  405. self.port = port
  406. self.use_tls = use_tls
  407. self.use_ssl = use_ssl
  408. self.default_sender = default_sender
  409. self.debug = debug
  410. self.max_emails = max_emails
  411. self.suppress = suppress
  412. self.ascii_attachments = ascii_attachments
  413. class Mail(_MailMixin):
  414. """Manages email messaging
  415. :param app: Flask instance
  416. """
  417. def __init__(self, app=None):
  418. self.app = app
  419. if app is not None:
  420. self.state = self.init_app(app)
  421. else:
  422. self.state = None
  423. def init_mail(self, config, debug=False, testing=False):
  424. return _Mail(
  425. config.get('MAIL_SERVER', '127.0.0.1'),
  426. config.get('MAIL_USERNAME'),
  427. config.get('MAIL_PASSWORD'),
  428. config.get('MAIL_PORT', 25),
  429. config.get('MAIL_USE_TLS', False),
  430. config.get('MAIL_USE_SSL', False),
  431. config.get('MAIL_DEFAULT_SENDER'),
  432. int(config.get('MAIL_DEBUG', debug)),
  433. config.get('MAIL_MAX_EMAILS'),
  434. config.get('MAIL_SUPPRESS_SEND', testing),
  435. config.get('MAIL_ASCII_ATTACHMENTS', False)
  436. )
  437. def init_app(self, app):
  438. """Initializes your mail settings from the application settings.
  439. You can use this if you want to set up your Mail instance
  440. at configuration time.
  441. :param app: Flask application instance
  442. """
  443. state = self.init_mail(app.config, app.debug, app.testing)
  444. # register extension with app
  445. app.extensions = getattr(app, 'extensions', {})
  446. app.extensions['mail'] = state
  447. return state
  448. def __getattr__(self, name):
  449. return getattr(self.state, name, None)
  450. signals = blinker.Namespace()
  451. email_dispatched = signals.signal("email-dispatched", doc="""
  452. Signal sent when an email is dispatched. This signal will also be sent
  453. in testing mode, even though the email will not actually be sent.
  454. """)