/kai/lib/mail.py

https://bitbucket.org/bbangert/kai/ · Python · 328 lines · 296 code · 8 blank · 24 comment · 4 complexity · fbdd76be59a8e3afece0c1f40d963d7f MD5 · raw file

  1. """Tools for sending email."""
  2. from email import Charset, Encoders
  3. from email.MIMEText import MIMEText
  4. from email.MIMEMultipart import MIMEMultipart
  5. from email.MIMEBase import MIMEBase
  6. from email.Header import Header
  7. from email.Utils import formatdate, parseaddr, formataddr
  8. import mimetypes
  9. import os
  10. import random
  11. import smtplib
  12. import socket
  13. import time
  14. import types
  15. from paste.deploy.converters import asbool
  16. from pylons import config
  17. # Don't BASE64-encode UTF-8 messages so that we avoid unwanted attention from
  18. # some spam filters.
  19. Charset.add_charset('utf-8', Charset.SHORTEST, Charset.QP, 'utf-8')
  20. # Default MIME type to use on attachments (if it is not explicitly given
  21. # and cannot be guessed).
  22. DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream'
  23. # Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of
  24. # seconds, which slows down the restart of the server.
  25. class CachedDnsName(object):
  26. def __str__(self):
  27. return self.get_fqdn()
  28. def get_fqdn(self):
  29. if not hasattr(self, '_fqdn'):
  30. self._fqdn = socket.getfqdn()
  31. return self._fqdn
  32. DNS_NAME = CachedDnsName()
  33. DEFAULT_CHARSET = 'utf-8'
  34. # Copied from Python standard library and modified to used the cached hostname
  35. # for performance.
  36. def make_msgid(idstring=None):
  37. """Returns a string suitable for RFC 2822 compliant Message-ID, e.g:
  38. <20020201195627.33539.96671@nightshade.la.mastaler.com>
  39. Optional idstring if given is a string used to strengthen the
  40. uniqueness of the message id.
  41. """
  42. timeval = time.time()
  43. utcdate = time.strftime('%Y%m%d%H%M%S', time.gmtime(timeval))
  44. pid = os.getpid()
  45. randint = random.randrange(100000)
  46. if idstring is None:
  47. idstring = ''
  48. else:
  49. idstring = '.' + idstring
  50. idhost = DNS_NAME
  51. msgid = '<%s.%s.%s%s@%s>' % (utcdate, pid, randint, idstring, idhost)
  52. return msgid
  53. def force_unicode(s, encoding='utf-8', strings_only=False, errors='strict'):
  54. """
  55. Similar to smart_unicode, except that lazy instances are resolved to
  56. strings, rather than kept as lazy objects.
  57. If strings_only is True, don't convert (some) non-string-like objects.
  58. """
  59. if strings_only and isinstance(s, (types.NoneType, int)):
  60. return s
  61. if not isinstance(s, basestring,):
  62. if hasattr(s, '__unicode__'):
  63. s = unicode(s)
  64. else:
  65. s = unicode(str(s), encoding, errors)
  66. elif not isinstance(s, unicode):
  67. s = unicode(s, encoding, errors)
  68. return s
  69. class BadHeaderError(ValueError):
  70. pass
  71. class SafeMIMEText(MIMEText):
  72. def __setitem__(self, name, val):
  73. "Forbids multi-line headers, to prevent header injection."
  74. if '\n' in val or '\r' in val:
  75. raise BadHeaderError, "Header values can't contain newlines (got %r for header %r)" % (val, name)
  76. try:
  77. val = str(force_unicode(val))
  78. except UnicodeEncodeError:
  79. if name.lower() in ('to', 'from', 'cc'):
  80. result = []
  81. for item in val.split(', '):
  82. nm, addr = parseaddr(item)
  83. nm = str(Header(nm, DEFAULT_CHARSET))
  84. result.append(formataddr((nm, str(addr))))
  85. val = ', '.join(result)
  86. else:
  87. val = Header(force_unicode(val), DEFAULT_CHARSET)
  88. MIMEText.__setitem__(self, name, val)
  89. class SafeMIMEMultipart(MIMEMultipart):
  90. def __setitem__(self, name, val):
  91. "Forbids multi-line headers, to prevent header injection."
  92. if '\n' in val or '\r' in val:
  93. raise BadHeaderError, "Header values can't contain newlines (got %r for header %r)" % (val, name)
  94. try:
  95. val = str(force_unicode(val))
  96. except UnicodeEncodeError:
  97. if name.lower() in ('to', 'from', 'cc'):
  98. result = []
  99. for item in val.split(', '):
  100. nm, addr = parseaddr(item)
  101. nm = str(Header(nm, DEFAULT_CHARSET))
  102. result.append(formataddr((nm, str(addr))))
  103. val = ', '.join(result)
  104. else:
  105. val = Header(force_unicode(val), DEFAULT_CHARSET)
  106. MIMEMultipart.__setitem__(self, name, val)
  107. class SMTPConnection(object):
  108. """
  109. A wrapper that manages the SMTP network connection.
  110. """
  111. def __init__(self, host=None, port=None, username=None, password=None,
  112. use_tls=None, fail_silently=False):
  113. self.host = host or config['smtp_server']
  114. self.port = port or config.get('smtp_port', 25)
  115. self.username = username or config.get('smtp_username', '')
  116. self.password = password or config.get('smtp_password', '')
  117. self.use_tls = (use_tls is not None) and use_tls or \
  118. asbool(config.get('smtp_use_tls', False))
  119. self.fail_silently = fail_silently
  120. self.connection = None
  121. def open(self):
  122. """
  123. Ensure we have a connection to the email server. Returns whether or not
  124. a new connection was required.
  125. """
  126. if self.connection:
  127. # Nothing to do if the connection is already open.
  128. return False
  129. try:
  130. self.connection = smtplib.SMTP(self.host, self.port)
  131. if self.use_tls:
  132. self.connection.ehlo()
  133. self.connection.starttls()
  134. self.connection.ehlo()
  135. if self.username and self.password:
  136. self.connection.login(self.username, self.password)
  137. return True
  138. except:
  139. if not self.fail_silently:
  140. raise
  141. def close(self):
  142. """Close the connection to the email server."""
  143. try:
  144. try:
  145. self.connection.quit()
  146. except socket.sslerror:
  147. # This happens when calling quit() on a TLS connection
  148. # sometimes.
  149. self.connection.close()
  150. except:
  151. if self.fail_silently:
  152. return
  153. raise
  154. finally:
  155. self.connection = None
  156. def send_messages(self, email_messages):
  157. """
  158. Send one or more EmailMessage objects and return the number of email
  159. messages sent.
  160. """
  161. if not email_messages:
  162. return
  163. new_conn_created = self.open()
  164. if not self.connection:
  165. # We failed silently on open(). Trying to send would be pointless.
  166. return
  167. num_sent = 0
  168. for message in email_messages:
  169. sent = self._send(message)
  170. if sent:
  171. num_sent += 1
  172. if new_conn_created:
  173. self.close()
  174. return num_sent
  175. def _send(self, email_message):
  176. """A helper method that does the actual sending."""
  177. if not email_message.to:
  178. return False
  179. try:
  180. self.connection.sendmail(email_message.from_email,
  181. email_message.recipients(),
  182. email_message.message().as_string())
  183. except:
  184. if not self.fail_silently:
  185. raise
  186. return False
  187. return True
  188. class EmailMessage(object):
  189. """
  190. A container for email information.
  191. """
  192. content_subtype = 'plain'
  193. multipart_subtype = 'mixed'
  194. encoding = None # None => use settings default
  195. def __init__(self, subject='', body='', from_email=None, to=None, bcc=None,
  196. connection=None, attachments=None, headers=None):
  197. """
  198. Initialise a single email message (which can be sent to multiple
  199. recipients).
  200. All strings used to create the message can be unicode strings (or UTF-8
  201. bytestrings). The SafeMIMEText class will handle any necessary encoding
  202. conversions.
  203. """
  204. self.to = to or []
  205. self.bcc = bcc or []
  206. self.from_email = from_email or config.get('smtp_from', '')
  207. self.subject = subject
  208. self.body = body
  209. self.attachments = attachments or []
  210. self.extra_headers = headers or {}
  211. self.connection = connection
  212. def get_connection(self, fail_silently=False):
  213. if not self.connection:
  214. self.connection = SMTPConnection(fail_silently=fail_silently)
  215. return self.connection
  216. def message(self):
  217. encoding = self.encoding or DEFAULT_CHARSET
  218. msg = SafeMIMEText(force_unicode(self.body, DEFAULT_CHARSET), self.content_subtype, encoding)
  219. if self.attachments:
  220. body_msg = msg
  221. msg = SafeMIMEMultipart(_subtype=self.multipart_subtype)
  222. if self.body:
  223. msg.attach(body_msg)
  224. for attachment in self.attachments:
  225. if isinstance(attachment, MIMEBase):
  226. msg.attach(attachment)
  227. else:
  228. msg.attach(self._create_attachment(*attachment))
  229. msg['Subject'] = self.subject
  230. msg['From'] = self.from_email
  231. msg['To'] = ', '.join(self.to)
  232. msg['Date'] = formatdate()
  233. msg['Message-ID'] = make_msgid()
  234. if self.bcc:
  235. msg['Bcc'] = ', '.join(self.bcc)
  236. for name, value in self.extra_headers.items():
  237. msg[name] = value
  238. return msg
  239. def recipients(self):
  240. """
  241. Returns a list of all recipients of the email (includes direct
  242. addressees as well as Bcc entries).
  243. """
  244. return self.to + self.bcc
  245. def send(self, fail_silently=False):
  246. """Send the email message."""
  247. return self.get_connection(fail_silently).send_messages([self])
  248. def attach(self, filename=None, content=None, mimetype=None):
  249. """
  250. Attaches a file with the given filename and content. The filename can
  251. be omitted (useful for multipart/alternative messages) and the mimetype
  252. is guessed, if not provided.
  253. If the first parameter is a MIMEBase subclass it is inserted directly
  254. into the resulting message attachments.
  255. """
  256. if isinstance(filename, MIMEBase):
  257. assert content == mimetype == None
  258. self.attachments.append(filename)
  259. else:
  260. assert content is not None
  261. self.attachments.append((filename, content, mimetype))
  262. def attach_file(self, path, mimetype=None):
  263. """Attaches a file from the filesystem."""
  264. filename = os.path.basename(path)
  265. content = open(path, 'rb').read()
  266. self.attach(filename, content, mimetype)
  267. def _create_attachment(self, filename, content, mimetype=None):
  268. """
  269. Convert the filename, content, mimetype triple into a MIME attachment
  270. object.
  271. """
  272. if mimetype is None:
  273. mimetype, _ = mimetypes.guess_type(filename)
  274. if mimetype is None:
  275. mimetype = DEFAULT_ATTACHMENT_MIME_TYPE
  276. basetype, subtype = mimetype.split('/', 1)
  277. if basetype == 'text':
  278. attachment = SafeMIMEText(content, subtype, DEFAULT_CHARSET)
  279. else:
  280. # Encode non-text attachments with base64.
  281. attachment = MIMEBase(basetype, subtype)
  282. attachment.set_payload(content)
  283. Encoders.encode_base64(attachment)
  284. if filename:
  285. attachment.add_header('Content-Disposition', 'attachment', filename=filename)
  286. return attachment
  287. class EmailMultiAlternatives(EmailMessage):
  288. """
  289. A version of EmailMessage that makes it easy to send multipart/alternative
  290. messages. For example, including text and HTML versions of the text is
  291. made easier.
  292. """
  293. multipart_subtype = 'alternative'
  294. def attach_alternative(self, content, mimetype=None):
  295. """Attach an alternative content representation."""
  296. self.attach(content=content, mimetype=mimetype)