From c98712916899305662859eb31168bee3866ce186 Mon Sep 17 00:00:00 2001 From: Sergey Lavrinenko Date: Sat, 21 Feb 2015 23:56:59 +0300 Subject: [PATCH] python-emails refactored and redefined --- .coveragerc | 8 + .gitignore | 1 + .travis.yml | 23 +- Makefile | 10 + README.rst | 151 ++++--- emails/__init__.py | 6 +- emails/exc.py | 4 + emails/loader/__init__.py | 117 ++++-- emails/loader/cssinliner.py | 170 -------- emails/loader/helpers.py | 50 +-- emails/loader/htmlloader.py | 392 ------------------ .../loader/{fileloader.py => local_store.py} | 116 +++++- emails/loader/stylesheets.py | 125 ------ emails/loader/wrappers.py | 96 ----- emails/message.py | 251 +++++++---- emails/smtp/backend.py | 29 +- emails/smtp/client.py | 48 ++- emails/smtp/factory.py | 2 +- emails/store/file.py | 56 ++- emails/store/store.py | 55 ++- emails/testsuite/conftest.py | 82 +++- .../test_django_integrations.py | 12 +- .../loader/data/html_import/oldornament.zip | Bin 96933 -> 88907 bytes .../oldornament/html/left_sidebar.html | 152 ------- .../oldornament/html/right_sidebar.html | 153 ------- .../oldornament/{html => }/images/arrow.png | Bin .../{html => }/images/banner-bottom.png | Bin .../{html => }/images/banner-middle.gif | Bin .../{html => }/images/banner-top.gif | Bin .../oldornament/{html => }/images/bg-all.jpg | Bin .../{html => }/images/bg-content.jpg | Bin .../oldornament/{html => }/images/bg-main.jpg | Bin .../oldornament/{html => }/images/divider.jpg | Bin .../{html => }/images/divider2.jpg | Bin .../{html => }/images/flourish.png | Bin .../oldornament/{html => }/images/img01.jpg | Bin .../oldornament/{html => }/images/img02.jpg | Bin .../oldornament/{html => }/images/img03.jpg | Bin .../oldornament/{html => }/images/spacer.gif | Bin .../{html/full_width.html => index.html} | 2 +- emails/testsuite/loader/test_loader.py | 208 ---------- emails/testsuite/loader/test_loaders.py | 106 +++++ emails/testsuite/message/helpers.py | 48 +-- emails/testsuite/message/test_dkim.py | 6 - emails/testsuite/message/test_message.py | 14 +- emails/testsuite/message/test_send.py | 87 ++-- .../testsuite/{loader => store}/test_store.py | 10 +- emails/testsuite/test_readme.py | 14 + emails/testsuite/test_utils.py | 11 + .../testsuite/transformer/test_transformer.py | 42 ++ emails/transformer.py | 315 ++++++++++++++ emails/utils.py | 33 +- requirements/base.txt | 1 + requirements/tests-2.6.txt | 4 +- requirements/tests-2.7.txt | 5 +- requirements/tests-3.3.txt | 5 +- requirements/tests-3.4.txt | 5 +- requirements/tests-base.txt | 3 + scripts/make_rfc822.py | 87 ++-- setup.py | 28 +- tox.ini | 26 ++ 61 files changed, 1374 insertions(+), 1795 deletions(-) create mode 100644 .coveragerc create mode 100644 Makefile create mode 100644 emails/exc.py delete mode 100644 emails/loader/cssinliner.py delete mode 100644 emails/loader/htmlloader.py rename emails/loader/{fileloader.py => local_store.py} (61%) delete mode 100644 emails/loader/stylesheets.py delete mode 100644 emails/loader/wrappers.py rename emails/testsuite/{smtp => django_}/test_django_integrations.py (62%) delete mode 100755 emails/testsuite/loader/data/html_import/oldornament/html/left_sidebar.html delete mode 100755 emails/testsuite/loader/data/html_import/oldornament/html/right_sidebar.html rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/arrow.png (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/banner-bottom.png (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/banner-middle.gif (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/banner-top.gif (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/bg-all.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/bg-content.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/bg-main.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/divider.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/divider2.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/flourish.png (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/img01.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/img02.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/img03.jpg (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html => }/images/spacer.gif (100%) rename emails/testsuite/loader/data/html_import/oldornament/{html/full_width.html => index.html} (99%) delete mode 100644 emails/testsuite/loader/test_loader.py create mode 100644 emails/testsuite/loader/test_loaders.py rename emails/testsuite/{loader => store}/test_store.py (68%) create mode 100644 emails/testsuite/test_readme.py create mode 100644 emails/testsuite/test_utils.py create mode 100644 emails/testsuite/transformer/test_transformer.py create mode 100644 emails/transformer.py create mode 100644 requirements/tests-base.txt create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2ac526f --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = emails + +[report] +omit = + emails/testsuite* + emails/packages* + emails/compat* diff --git a/.gitignore b/.gitignore index 9dc11a4..c84f441 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ local_settings.py +local_*_settings.py *.py[cod] # C extensions diff --git a/.travis.yml b/.travis.yml index e8f3f4c..7104f03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,20 @@ language: python +sudo: no + python: - "2.6" - "2.7" - "3.3" - "3.4" -script: py.test +script: py.test --cov emails + +before_install: + - travis_retry pip install coverage coveralls pytest-cov install: - - pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt --use-mirrors + - travis_retry pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt env: - PIP_DOWNLOAD_CACHE=$HOME/.pip-cache @@ -17,3 +22,17 @@ env: cache: directories: - $HOME/.pip-cache/ + +after_success: + # Report coverage results to coveralls.io + - coveralls + + deploy: + provider: pypi + user: lavr + password: + secure: "WuFOsmKW77foHa0Ywv7pwXNvSQ+lHSx/IlYxPTuE7dTj1mNgvXC48NXQONY1ZEDiysryimgfsqumvx6PqLsFmOkG4r9k3gaau0eHE063+/hse0YvbqpnzIWa1FTe4yxreJeEHWSiNyAyo0ERaZVMcnj1ii6paHzuMVuCQ/BwV3k=" + on: + branch: master + tags: true + distributions: "sdist bdist_wheel" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..60768f0 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ + +clean: + find . -name '*pyc' -exec rm -f {} \; + find . -name '*py~' -exec rm -f {} \; + +test: + tox + +pypi: + python setup.py sdist upload diff --git a/README.rst b/README.rst index 0fc7672..be8aab2 100644 --- a/README.rst +++ b/README.rst @@ -1,11 +1,23 @@ python-emails ============= -Emails without pain for python. +Modern email handling in python. + +Features +-------- + +- HTML-email message abstraction +- Method to transform html body: + + - css inlining (using peterbe's premailer) + - image inlining +- DKIM signature +- Message loaders +- Send directly or via django email backend -What can you do: ----------------- +Examples: +--------- Create message: @@ -21,54 +33,106 @@ Attach files or inline images: :: - message.attach( data=open('event.ics'), filename='Event.ics' ) - message.attach( data=open('image.png'), filename='image.png', content_disposition='inline' ) + message.attach(data=open('event.ics'), filename='Event.ics') + message.attach(data=open('image.png'), filename='image.png', + content_disposition='inline') -Add DKIM easily: - -:: - - message.dkim( key=open('my.key'), domain='mycompany.com', selector='newsletter' ) - - - -Templating: +Use templates: :: from emails.template import JinjaTemplate as T - message = emails.html(subject=T('Payment Receipt No.{{no}}'), - html=T('

Dear {{ name }}! This is a receipt for your subscription...'), + message = emails.html(subject=T('Payment Receipt No.{{ billno }}'), + html=T('

Dear {{ name }}! This is a receipt...'), mail_from=('ABC', 'robot@mycompany.com')) - message.send(to=('John Brown', 'jbrown@gmail.com'), render={'name': 'John Brown', 'billno':'141051906163'} ) + message.send(to=('John Brown', 'jbrown@gmail.com'), + render={'name': 'John Brown', 'billno': '141051906163'}) -Send without pain and (even) get response: + + +Add DKIM signature: :: - SMTP = { 'host':'smtp.mycompany.com', 'port': 465, 'ssl': True } - r = message.send(to=('John Brown', 'jbrown@gmail.com'), smtp=SMTP) + message.dkim(key=open('my.key'), domain='mycompany.com', selector='newsletter') + +Generate email.message or rfc822 string: + +:: + + m = message.as_message() + s = message.as_string() + + + +Send and get response from smtp server: + +:: + + r = message.send(to=('John Brown', 'jbrown@gmail.com'), + smtp={'host':'smtp.mycompany.com', 'port': 465, 'ssl': True}) assert r.status_code == 250 +Or send via Django email backend: + +:: + from django.core.mail import get_connection + from emails.message import DjangoMessageProxy + c = django.core.mail.get_connection() + c.send_messages([DjangoMessageProxy(message), ]) +HTML transformer +---------------- -One more thing --------------- -Library ships with fairy email-from-html loader. -Design email with less pain or even let designers make design: +Message HTML body can be modified with 'transformer' object: :: - import emails - URL = 'http://_youproject_.github.io/newsletter/2013-08-14/index.html' - page = emails.loader.from_url(URL, css_inline=True, make_links_absolute=True) - message = emails.html(html=page.html, ...) - for mail_to in _get_maillist(): - message.send(to=mail_to) + >>> message = emails.Message(html="") + >>> message.transformer.apply_to_images(func=lambda src, **kw: 'http://mycompany.tld/images/'+src) + >>> message.transformer.save() + >>> message.html + u'' + +Code example to make images inline: + +:: + + >>> message = emails.Message(html="") + >>> message.attach(filename='promo.png', data=open('promo.png')) + >>> message.attachments['promo.png'].is_inline = True + >>> message.transformer.synchronize_inline_images() + >>> message.transformer.save() + >>> message.html + u'' + + +Loaders +------- + +python-emails ships with couple of loaders. + +Load message from url: + +:: + + import emails.loader + message = emails.loader.from_url(url="http://xxx.github.io/newsletter/2015-08-14/index.html") + + +Load from zipfile or directory: + +:: + + message = emails.loader.from_zipfile(open('design_pack.zip')) + message = emails.loader.from_directory('/home/user/design_pack') + +Zipfile and directory loaders require at least one html file (with "html" extension). + Install ------- @@ -88,30 +152,18 @@ Install on Ubuntu from PPA: $ [sudo] apt-get install python-emails -Features --------- - -- Internationalization & Unicode bodies -- DKIM signatures -- HTML page loader & CSS inliner -- Body and attachments http import -- Body & headers preprocessors TODO ---- -- Python3 (almost done) -- Add "safety stuff" from django (done) -- Django integration (django.core.mail.backends.smtp.EmailBackend subclass) -- Flask extension + - Documentation - 100% test coverage - More accurate smtp session handling -- Some patches for pydkim performance (i.e. preload key once, not each time) -- More genius css inliner -- Catch all bugs - ESP integration: Amazon SES, SendGrid, ... -- deb package (ubuntu package done) +- deb package (ubuntu package almost done) - rpm package +- Patch pydkim for performance (i.e. preload key once, not each time) +- Flask extension How to Help ----------- @@ -124,13 +176,6 @@ Library is under development and contributions are welcome! 4. Send a pull request. Make sure to add yourself to AUTHORS. -Background ----------- - -API structure inspired by python-requests and werkzeug libraries. -Some code is from my mailcube.ru experience. - - See also -------- diff --git a/emails/__init__.py b/emails/__init__.py index 1a49942..518c474 100644 --- a/emails/__init__.py +++ b/emails/__init__.py @@ -23,10 +23,12 @@ More examples is at . """ __title__ = 'emails' -__version__ = '0.1.13' +__version__ = '0.2' __author__ = 'Sergey Lavrinenko' __license__ = 'Apache 2.0' -__copyright__ = 'Copyright 2013 Sergey Lavrinenko' +__copyright__ = 'Copyright 2013-2015 Sergey Lavrinenko' + +USER_AGENT = 'python-emails/%s' % __version__ from .message import Message, html from .utils import MessageID diff --git a/emails/exc.py b/emails/exc.py new file mode 100644 index 0000000..377b4d4 --- /dev/null +++ b/emails/exc.py @@ -0,0 +1,4 @@ +# encoding: utf-8 + +class HTTPLoaderError(Exception): + pass \ No newline at end of file diff --git a/emails/loader/__init__.py b/emails/loader/__init__.py index bbdfe91..4b7bf91 100644 --- a/emails/loader/__init__.py +++ b/emails/loader/__init__.py @@ -1,44 +1,99 @@ # encoding: utf-8 -import os, os.path -import logging -from .htmlloader import HTTPLoader -from .fileloader import FileSystemLoader, ZipLoader -from .stylesheets import PageStylesheets +import os +import os.path +from emails.loader.helpers import guess_charset +from emails.compat import to_unicode +from emails.compat import urlparse +from emails import Message +from emails.utils import fetch_url +from emails.loader import local_store -def from_url(url, **kwargs): - loader = HTTPLoader() - loader.load_url(url=url, **kwargs) - return loader +def from_url(url, message_params=None, requests_params=None, **kwargs): + + def _make_base_url(url): + # /a/b.html -> /a + p = list(urlparse.urlparse(url))[:5] + p[2] = os.path.split(p[2])[0] + return urlparse.urlunsplit(p) + + # Load html page + r = fetch_url(url, requests_args=requests_params) + html = r.content + html = to_unicode(html, charset=guess_charset(r.headers, html)) + html = html.replace('\r\n', '\n') # Remove \r + + message_params = message_params or {} + message = Message(html=html, **message_params) + message.create_transformer(requests_params=requests_params, + base_url=_make_base_url(url)) + message.transformer.load_and_transform(**kwargs) + message.transformer.save() + return message load_url = from_url + +def from_directory(directory, index_file=None, message_params=None, **kwargs): + + store = local_store.FileSystemLoader(searchpath=directory) + index_file_name = store.find_index_file(index_file) + dirname, _ = os.path.split(index_file_name) + if dirname: + store.base_path = dirname + + message_params = message_params or {} + message = Message(html=store[index_file_name], **message_params) + message.create_transformer(local_loader=store, requests_params=kwargs.get('requests_params')) + message.transformer.load_and_transform(**kwargs) + message.transformer.save() + return message + + def from_file(filename, **kwargs): return from_directory(directory=os.path.dirname(filename), index_file=os.path.basename(filename), **kwargs) -def from_directory(directory, index_file=None, **kwargs): - loader = HTTPLoader() - local_loader = FileSystemLoader(searchpath=directory) - index_file_name = local_loader.find_index_file(index_file) - dirname, basename = os.path.split(index_file_name) + +def from_zip(zip_file, message_params=None, **kwargs): + store = local_store.ZipLoader(file=zip_file) + index_file_name = store.find_index_file() + dirname, index_file_name = os.path.split(index_file_name) if dirname: - local_loader.base_path = dirname - loader.load_file(local_loader[basename], local_loader=local_loader, **kwargs) - return loader + store.base_path = dirname -def from_zip(zip_file, **kwargs): - loader = HTTPLoader() - local_store = ZipLoader(file=zip_file) - index_file_name = local_store.find_index_file() - dirname, basename = os.path.split(index_file_name) - if dirname: - local_store.base_path = dirname - logging.debug('from_zip: found index file: %s', index_file_name) - loader.load_file(local_store[basename], local_loader=local_store, **kwargs) - return loader + message_params = message_params or {} + message = Message(html=store[index_file_name], **message_params) + message.create_transformer(local_loader=store, requests_params=kwargs.get('requests_params')) + message.transformer.load_and_transform(**kwargs) + message.transformer.save() + return message -def from_string(html, css=None, **kwargs): - loader = HTTPLoader() - loader.load_string(html=html, css=css, **kwargs) - return loader +def from_html(html, base_url=None, message_params=None, **kwargs): + message_params = message_params or {} + message = Message(html=html, **message_params) + message.create_transformer(requests_params=kwargs.get('requests_params'), base_url=base_url) + message.transformer.load_and_transform(**kwargs) + message.transformer.save() + return message + +from_string = from_html + + +def from_rfc822(msg, message_params=None, **kw): + + store = local_store.MsgLoader(msg=msg) + text = store.get_source('__index.txt') + html = store.get_source('__index.html') + + message_params = message_params or {} + message = Message(html=html, text=text, **message_params) + if html: + message.create_transformer(local_loader=store, **kw) + message.transformer.load_and_transform() + message.transformer.save() + else: + # TODO: add attachments for text-only message + pass + + return message \ No newline at end of file diff --git a/emails/loader/cssinliner.py b/emails/loader/cssinliner.py deleted file mode 100644 index aa58b8e..0000000 --- a/emails/loader/cssinliner.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- -# adapted from https://github.com/kgn/cssutils/blob/master/examples/style.py -from __future__ import unicode_literals, print_function -import logging - -from cssutils.css import CSSStyleSheet, CSSStyleDeclaration, CSSStyleRule -from cssutils import CSSParser -from lxml import etree - -from emails.compat import to_unicode, string_types -import emails - - -# Workaround the missing python3-cssselect package -# If no system-installed cssselect library found, use one from our distribution -try: - import cssselect -except ImportError: - import sys, os.path - my_packages = os.path.dirname(emails.packages.__file__) - sys.path.insert(0, my_packages) - import cssselect - -from lxml.cssselect import CSSSelector, ExpressionError - -class CSSInliner: - - NONVISUAL_TAGS = ['html', 'head', 'title', 'meta', 'link', 'script'] - - DEBUG = False - - def __init__(self, base_url=None, css=None): - - self.stylesheet = CSSStyleSheet(href=base_url) - self.base_url = base_url - if css: - self.add_css(css) - - def add_css(self, css, href=None): - - if isinstance(css, string_types): - css = CSSParser().parseString(css, href=href) # Распарсим файл - - for rule in css: - self.stylesheet.add(rule) - - def log(self, level, *msg): - if self.DEBUG: - print(('%s- %s' % (level * '\t ', ' '.join((to_unicode(m or '') for m in msg))))) - - def styleattribute(self, element): - """returns css.CSSStyleDeclaration of inline styles, for html: @style""" - cssText = element.get('style') - if cssText: - try: - return CSSStyleDeclaration(cssText=cssText) - except Exception as e: - # Sometimes here's error like "COLOR: ;" - logging.exception('Exception in styleattribute %s', cssText) - return None - else: - return None - - def getView(self, document, sheet, media='all', name=None, styleCallback=None): - """ - document - a DOM document, currently an lxml HTML document - sheet - a CSS StyleSheet object, currently cssutils sheet - media: optional - TODO: view for which media it should be - name: optional - TODO: names of sheets only - styleCallback: optional - should return css.CSSStyleDeclaration of inline styles, for html - a style declaration for ``element@style``. Gets one parameter - ``element`` which is the relevant DOMElement - - returns style view - a dict of {DOMElement: css.CSSStyleDeclaration} for html - """ - - styleCallback = styleCallback or self.styleattribute - - _unmergable_rules = CSSStyleSheet() - - view = {} - specificities = {} # needed temporarily - - # TODO: filter rules simpler?, add @media - rules = (rule for rule in sheet if rule.type == rule.STYLE_RULE) - for rule in rules: - for selector in rule.selectorList: - self.log(0, 'SELECTOR', selector.selectorText) - # TODO: make this a callback to be able to use other stuff than lxml - try: - cssselector = CSSSelector(selector.selectorText) - except (ExpressionError, NotImplementedError) as e: - _unmergable_rules.add(CSSStyleRule(selectorText=selector.selectorText, - style=rule.style)) - continue - - matching = cssselector.evaluate(document) - - for element in matching: - - if element.tag in self.NONVISUAL_TAGS: - continue - - # add styles for all matching DOM elements - self.log(1, 'ELEMENT', id(element), element.text) - - if element not in view: - # add initial empty style declatation - view[element] = CSSStyleDeclaration() - specificities[element] = {} - - # and add inline @style if present - inlinestyle = styleCallback(element) - if inlinestyle: - for p in inlinestyle: - # set inline style specificity - view[element].setProperty(p) - specificities[element][p.name] = (1, 0, 0, 0) - - for p in rule.style: - # update style declaration - if p not in view[element]: - # setProperty needs a new Property object and - # MUST NOT reuse the existing Property - # which would be the same for all elements! - # see Issue #23 - view[element].setProperty(p.name, p.value, p.priority) - specificities[element][p.name] = selector.specificity - self.log(2, view[element].getProperty('color')) - - else: - self.log(2, view[element].getProperty('color')) - sameprio = (p.priority == - view[element].getPropertyPriority(p.name)) - if not sameprio and bool(p.priority) or ( - sameprio and selector.specificity >= - specificities[element][p.name]): - # later, more specific or higher prio - view[element].setProperty(p.name, p.value, p.priority) - - _unmergable_css = _unmergable_rules.cssText - if _unmergable_css: - e = etree.Element('style') - e.text = to_unicode(_unmergable_css, 'utf-8') - body = document.find('body') or document - body.insert(0, e) # add - """ - if el.text: - self.stylesheets.append(text=el.text, url=self.start_url) - - def iter_image_links(self): - return (_ for _ in self._tags_with_images) - - def iter_a_links(self): - return (_ for _ in self._a_links) - - def process_tag_with_link(self, el): - """ - Process IMG SRC, TABLE BACKGROUND, ... - """ - obj = self.tag_link_cls[el.tag](el, encoding=self.html_encoding) - if obj.link is None: - return - - self._tags_with_links.append(obj) - if el.tag in self.TAGS_WITH_IMAGES: - lnk = obj.link - if lnk is not None: - self._tags_with_images.append(obj) - elif el.tag == 'a': - self._a_links.append(obj) - - def attach_all_images(self): - for obj in self.iter_image_links(): - lnk = obj.link - if lnk: - self.attach_image(uri=lnk, absolute_url=self.absolute_url(lnk)) - - def attach_image(self, uri, absolute_url, subtype=None): - if uri not in self.filestore: - self.filestore.add(self.attached_image_cls( - uri=uri, - absolute_url=absolute_url, - local_loader=self.local_loader, - subtype=subtype, - fetch_params=self.fetch_params)) - - def process_tag_with_style(self, el): - t = StyledTagWrapper(el) - for p in t.uri_properties(): - obj = self.css_link_cls(p, updateme=t) - self._tags_with_links.append(obj) - self._tags_with_images.append(obj) - - def process_stylesheet_uri_property(self, prop): - obj = self.css_link_cls(prop) - self._tags_with_links.append(obj) - self._tags_with_images.append(obj) - - def make_link_absolute(self, obj): - link = obj.link - if link: - obj.link = self.absolute_url(link) - - def make_images_inline(self): - - found_links = set() - - for img in self.iter_image_links(): - link = img.link - found_links.add(link) - file = self.filestore.by_uri(link, img.link_history) - img.link = "cid:%s" % file.filename - - for file in self.filestore: - if file.uri in found_links: - file.content_disposition = 'inline' - else: - logging.debug('make_images_inline %s=none', file.uri) - - def set_content_type_meta(self): - _tree = self.html_tree - new_document = helpers.set_content_type_meta(_tree, element_cls=etree.Element) - if _tree != new_document: - # document may be updated here (i.e. html tag added) - self.html_tree = new_document - - def insert_big_stylesheet(self): - return helpers.add_body_stylesheet(self.html_tree, element_cls=etree.Element, - tag="body", cssText="") - - def absolute_url(self, url, base_url=None): - - # In: some url - # Out: (absolute_url, relative_url) based on self._base_url - - if base_url is None: - base_url = self.base_url - - if base_url is None: - return url - - parsed_url = urlparse.urlsplit(url) - if parsed_url.scheme: - # is absolute_url - return url - else: - # http://xxx.com/../../style.css -> http://xxx.com/style.css - # см. http://teethgrinder.co.uk/perm.php?a=Normalize-URL-path-python - joined = urlparse.urljoin(self.base_url, url) - url = urlparse.urlparse(joined) - path = posixpath.normpath(url[2]) - return urlparse.urlunparse((url.scheme, url.netloc, path, url.params, url.query, url.fragment)) - - def doinlinecss(self): - self.html_tree = CSSInliner(css=self.stylesheets.stylesheet).transform(html=self.html_tree) - - @property - def html(self): - self.stylesheets.update_tag() - self._html = etree.tostring(self.html_tree, encoding=self.encoding, method='xml') - return to_unicode(self._html, self.encoding) - - @property - def attachments_dict(self): - return list(self.filestore.as_dict()) - - def save_to_file(self, filename): - # - # Not very good example of link walking and file rename - # - - path = os.path.abspath(filename) - # Save images locally and replace all links to images in html - files_dir = '_files' - _rename_map = {} - - for obj in self.iter_image_links(): - uri = obj.link - if uri is None: - continue - _new_uri = _rename_map.get(uri, None) - if _new_uri is None: - file = self.filestore.by_uri(uri, synonims=obj.link_history) - if file is None: - logging.warning( - 'file "%s" not found in attachments, this should not happen. skipping', uri) - continue - _new_uri = _rename_map[uri] = os.path.join(files_dir, file.filename) - obj.link = _new_uri - - try: - os.makedirs(files_dir) - except OSError: - pass - for attach in self.filestore: - attach.fetch() - new_uri = _rename_map.get(attach.uri) - if new_uri: - attach.uri = new_uri - open(new_uri, 'wb').write(attach.data) - - f = open(filename, 'wb') - f.write(to_bytes(self.html, 'utf-8')) - f.close() - diff --git a/emails/loader/fileloader.py b/emails/loader/local_store.py similarity index 61% rename from emails/loader/fileloader.py rename to emails/loader/local_store.py index d244c4e..bb97e42 100644 --- a/emails/loader/fileloader.py +++ b/emails/loader/local_store.py @@ -1,15 +1,15 @@ # encoding: utf-8 from __future__ import unicode_literals import logging +import mimetypes import os from os import path import errno from zipfile import ZipFile +import email from emails.compat import to_unicode, string_types -# FileSystemLoader adapted from jinja2.loaders - class FileNotFound(Exception): pass @@ -74,6 +74,8 @@ class BaseLoader(object): raise FileNotFound('index html') +# FileSystemLoader from jinja2.loaders + class FileSystemLoader(BaseLoader): """Loads templates from the file system. This loader can find templates in folders on the file system and is the preferred way to load them. @@ -155,11 +157,8 @@ class ZipLoader(BaseLoader): def get_source(self, name): - logging.debug('ZipLoader.get_source %s', name) - if self.base_path: name = path.join(self.base_path, name) - logging.debug('ZipLoader.get_source has base_path, result name is %s', name) self._unpack_zip() @@ -173,16 +172,117 @@ class ZipLoader(BaseLoader): original_name = self._filenames.get(name) - logging.debug('ZipLoader.get_source original_name=%s', original_name) - if original_name is None: raise FileNotFound(name) data = self.zipfile.read(original_name) - logging.debug('ZipLoader.get_source returns %s bytes', len(data)) return data, name def list_files(self): self._unpack_zip() return sorted(self._filenames) + + +class MsgLoader(BaseLoader): + """ + Load files from email.Message + Thanks to + http://blog.magiksys.net/parsing-email-using-python-content + """ + + common_charsets = ['ascii', 'utf-8', 'utf-16', 'windows-1252', 'cp850', 'windows-1251'] + + def __init__(self, msg, base_path=None): + if isinstance(msg, string_types): + self.msg = email.message_from_string(msg) + else: + self.msg = msg + self.base_path = base_path + self._html_files = [] + self._text_files = [] + self._files = {} + + + def decode_text(self, text, charset=None): + if charset: + try: + return text.decode(charset), charset + except UnicodeError: + pass + for charset in self.common_charsets: + try: + return text.decode(charset), charset + except UnicodeError: + pass + return text, None + + def clean_content_id(self, content_id): + if content_id.startswith('<'): + content_id = content_id[1:] + if content_id.endswith('>'): + content_id = content_id[:-1] + return content_id + + def extract_part_text(self, part): + return self.decode_text(part.get_payload(decode=True), charset=part.get_param('charset'))[0] + + def add_html_part(self, part): + name = '__index.html' + self._files[name] = {'data': self.extract_part_text(part), + 'filename': name, + 'content_type': part.get_content_type()} + + def add_text_part(self, part): + name = '__index.txt' + self._files[name] = {'data': self.extract_part_text(part), + 'filename': name, + 'content_type': part.get_content_type()} + + def add_another_part(self, part): + counter = 1 + f = {} + content_id = part['Content-ID'] + if content_id: + f['filename'] = self.clean_content_id(content_id) + f['inline'] = True + else: + filename = part.get_filename() + if not filename: + ext = mimetypes.guess_extension(part.get_content_type()) + if not ext: + # Use a generic bag-of-bits extension + ext = '.bin' + filename = 'part-%03d%s' % (counter, ext) + counter += 1 + f['filename'] = filename + f['content_type'] = part.get_content_type() + f['data'] = part.get_payload(decode=True) + self._files[f['filename']] = f + + def _parse_msg(self): + for part in self.msg.walk(): + content_type = part.get_content_type() + + if content_type.startswith('multipart/'): + continue + + if content_type == 'text/html': + self.add_html_part(part) + continue + + if content_type == 'text/plain': + self.add_text_part(part) + continue + + self.add_another_part(part) + + def get_source(self, name): + self._parse_msg() + f = self._files.get(name) + if f: + return f['data'], name + return None, name + + def list_files(self): + return self._files \ No newline at end of file diff --git a/emails/loader/stylesheets.py b/emails/loader/stylesheets.py deleted file mode 100644 index c5ea8fb..0000000 --- a/emails/loader/stylesheets.py +++ /dev/null @@ -1,125 +0,0 @@ -# encoding: utf-8 -from __future__ import unicode_literals, print_function -import logging - -from cssutils.css import CSSStyleSheet -from cssutils import CSSParser -import cssutils - -from emails.compat import to_unicode - - -class PageStylesheets: - - """ - Store all html page styles and generates concatenated stylesheet - """ - - def __init__(self): - self.urls = set() - self._uri_properties = [] - self.sheets = [] - self.dirty = True - self.element = None - - def update_tag(self): - if self.element is not None: - self._concatenate_sheets() - cssText = self._cached_stylesheet.cssText - cssText = cssText and to_unicode(cssText, 'utf-8') or '' - self.element.text = cssText - - def attach_tag(self, element): - self.element = element - - def append(self, url=None, text=None, absolute_url=None, local_loader=None): - if (url is not None) and (url in self.urls): - logging.debug('stylesheet url duplicate: %s', url) - return - self.sheets.append({'url': url, 'text': text, 'absolute_url': absolute_url or url, - 'local_loader': local_loader}) - self.dirty = True - - def _concatenate_sheets(self): - if self.dirty or (self._cached_stylesheet is None): - r = CSSStyleSheet() - uri_properties = [] - - for d in self.sheets: - local_loader = d.get('local_loader', None) - text = d.get('text', None) - uri = d.get('uri', None) - absolute_url = d.get('absolute_url', None) - - if (text is None) and local_loader and uri: - text = local_loader[uri] - - if text: - sheet = CSSParser().parseString(text, href=absolute_url) - else: - sheet = cssutils.parseUrl(href=absolute_url) - - for rule in sheet: - r.add(rule) - for p in _get_rule_uri_properties(rule): - uri_properties.append(p) - - self._uri_properties = uri_properties - self._cached_stylesheet = r - self.dirty = False - - @property - def stylesheet(self): - self._concatenate_sheets() - return self._cached_stylesheet - - @property - def uri_properties(self): - self._concatenate_sheets() - return self._uri_properties - - -class StyledTagWrapper: - - def __init__(self, el): - self.el = el - self.style = CSSParser().parseStyle(el.get('style')) - - def update(self): - cssText = self.style.cssText - if isinstance(cssText, str): - cssText = to_unicode(cssText, 'utf-8') - self.el.set('style', cssText) - - def uri_properties(self): - for p in self.style.getProperties(all=True): - for v in p.propertyValue: - if v.type == 'URI': - yield v - - -# Stuff for extracting 'uri-properties' from CSS -# Inspired by cssutils examples - -def _style_declarations(base): - """recursive generator to find all CSSStyleDeclarations""" - if hasattr(base, 'cssRules'): - for rule in base.cssRules: - for s in _style_declarations(rule): - yield s - elif hasattr(base, 'style'): - yield base.style - - -def _get_rule_uri_properties(rule): - for style in _style_declarations(rule): - for p in style.getProperties(all=True): - for v in p.propertyValue: - if v.type == 'URI': - yield v - - -def get_stylesheets_uri_properties(sheet): - for rule in sheet: - for p in _get_rule_uri_properties(rule): - yield p diff --git a/emails/loader/wrappers.py b/emails/loader/wrappers.py deleted file mode 100644 index 15aca47..0000000 --- a/emails/loader/wrappers.py +++ /dev/null @@ -1,96 +0,0 @@ -# encoding: utf-8 - -# tag-with-link wrapper -from __future__ import unicode_literals -import logging -from emails.compat import OrderedSet, to_unicode - - -class ElementWithLink(object): - - LINK_ATTR_NAME = None - - def __init__(self, el, encoding=None): - self.el = el - self._link_history = OrderedSet() - self.encoding = encoding - - def get_link(self): - r = self.el.get(self.LINK_ATTR_NAME) - if self.encoding: - r = to_unicode(r, self.encoding) - return r - - def set_link(self, new): - _old = self.get_link() - if _old != new: - logging.debug('Update link %s => %s ', _old, new) - self.el.set(self.LINK_ATTR_NAME, new) - self._link_history.add(_old) - - link = property(get_link, set_link) - - @classmethod - def make(cls, attr): - def wrapper(el, encoding): - r = cls(el, encoding=encoding) - r.LINK_ATTR_NAME = attr - return r - return wrapper - - @property - def link_history(self): - return self._link_history - - -class A_link(ElementWithLink): - # el is lxml.Element - LINK_ATTR_NAME = 'href' - - -class Link_link(ElementWithLink): - # el is lxml.Element - LINK_ATTR_NAME = 'href' - - -class IMG_link(ElementWithLink): - # el is lxml.Element - LINK_ATTR_NAME = 'src' - - -class Background_link(ElementWithLink): - LINK_ATTR_NAME = 'background' - - -class CSS_link(ElementWithLink): - - # el is cssutils style property - - def __init__(self, el, updateme=None, encoding=None): - ElementWithLink.__init__(self, el) - self.updateme = updateme - self.encoding = encoding - - def get_link(self): - r = self.el.uri - if self.encoding: - r = to_unicode(self.el.uri, self.encoding) - return r - - def set_link(self, new): - _old = self.el.uri - if _old != new: - logging.debug('Update link %s => %s ', _old, new) - self.el.uri = new - self._link_history.add(_old) - if self.updateme: - self.updateme.update() - - link = property(get_link, set_link) - - -def TAG_WRAPPER(attr): - return ElementWithLink.make(attr) - - -CSS_WRAPPER = CSS_link diff --git a/emails/message.py b/emails/message.py index 215aa9a..6f27a4d 100644 --- a/emails/message.py +++ b/emails/message.py @@ -7,28 +7,19 @@ from functools import wraps from dateutil.parser import parse as dateutil_parse from email.header import Header from email.utils import formatdate, getaddresses - from emails.compat import string_types, to_unicode, is_callable, to_bytes - -from .utils import SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email +from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address, + parse_name_and_email, load_email_charsets, + encode_header as encode_header_) from .smtp import ObjectFactory, SMTPBackend from .store import MemoryFileStore, BaseFile from .signers import DKIMSigner -from .utils import load_email_charsets - load_email_charsets() # sic! -ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n' - - class BadHeaderError(ValueError): pass -# Header names that contain structured address data (RFC #5322) -ADDRESS_HEADERS = set(['from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'resent-from', 'resent-sender', 'resent-to', - 'resent-cc', 'resent-bcc']) - def renderable(f): @wraps(f) @@ -48,23 +39,22 @@ class IncompleteMessage(Exception): pass -class Message(object): - """ - Email class - message = HtmlEmail() - Message parts: - * html - * text - * attachments +class BaseMessage(object): """ + Base email message with html part, text part and attachments. + """ + + ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n' + + # Header names that contain structured address data (RFC #5322) + ADDRESS_HEADERS = set(['from', 'sender', 'reply-to', 'to', 'cc', 'bcc', + 'resent-from', 'resent-sender', 'resent-to', + 'resent-cc', 'resent-bcc']) attachment_cls = BaseFile - dkim_cls = DKIMSigner - smtp_pool_factory = ObjectFactory - smtp_cls = SMTPBackend filestore_cls = MemoryFileStore def __init__(self, @@ -87,20 +77,25 @@ class Message(object): self.set_mail_from(mail_from) self.set_mail_to(mail_to) self.set_headers(headers) - self.set_html(html=html) # , url=self.html_from_url) - self.set_text(text=text) # , url=self.text_from_url) + self.set_html(html=html) + self.set_text(text=text) self.render_data = {} - self._dkim_signer = None + self.after_build = None + if attachments: for a in attachments: self.attachments.add(a) - self.after_build = None - def set_mail_from(self, mail_from): # In: ('Alice', '' ) self._mail_from = mail_from and parse_name_and_email(mail_from) or None + def get_mail_from(self): + # Out: ('Alice', '') or None + return self._mail_from + + mail_from = property(get_mail_from, set_mail_from) + def set_mail_to(self, mail_to): # Now we parse only one to-addr # TODO: parse list of to-addrs @@ -121,25 +116,40 @@ class Message(object): self._html = html self._html_url = url + def get_html(self): + return self._html + + html = property(get_html, set_html) + def set_text(self, text, url=None): if hasattr(text, 'read'): text = text.read() self._text = text self._text_url = url - def attach(self, **kwargs): - if 'content_disposition' not in kwargs: - kwargs['content_disposition'] = 'attachment' - self.attachments.add(kwargs) + def get_text(self): + return self._text + + text = property(get_text, set_text) @classmethod def from_loader(cls, loader, template_cls=None, **kwargs): """ - Get html and attachments from HTTPLoader + Get html and attachments from Loader """ - message = cls(html=template_cls and template_cls(loader.html) or loader.html, **kwargs) - for att in loader.filestore: - message.attach(**att.as_dict()) + + html = loader.html + if html and template_cls: + html = template_cls(html) + + text = loader.text + if text and template_cls: + text = template_cls(text) + + message = cls(html=html, text=text, **kwargs) + + for attachment in loader.attachments: + message.attach(**attachment.as_dict()) return message @property @@ -164,12 +174,6 @@ class Message(object): def render(self, **kwargs): self.render_data = kwargs - @property - def attachments(self): - if self._attachments is None: - self._attachments = self.filestore_cls(self.attachment_cls) - return self._attachments - def set_date(self, value): if isinstance(value, string_types): _d = dateutil_parse(value) @@ -197,13 +201,7 @@ class Message(object): return is_callable(mid) and mid() or mid def encode_header(self, value): - value = to_unicode(value, charset=self.charset) - if isinstance(value, string_types): - value = value.rstrip() - _r = Header(value, self.charset) - return str(_r) - else: - return value + return encode_header_(value, self.charset) def encode_name_header(self, realname, email): if realname: @@ -222,18 +220,29 @@ class Message(object): if '\n' in value or '\r' in value: raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (value, key)) - if key.lower() in ADDRESS_HEADERS: + if key.lower() in self.ADDRESS_HEADERS: value = ', '.join(sanitize_address(addr, self.charset) for addr in getaddresses((value,))) msg[key] = encode and self.encode_header(value) or value + @property + def attachments(self): + if self._attachments is None: + self._attachments = self.filestore_cls(self.attachment_cls) + return self._attachments + + def attach(self, **kwargs): + if 'content_disposition' not in kwargs: + kwargs['content_disposition'] = 'attachment' + self.attachments.add(kwargs) + def _build_message(self, message_cls=None): message_cls = message_cls or SafeMIMEMultipart msg = message_cls() - msg.preamble = ROOT_PREAMBLE + msg.preamble = self.ROOT_PREAMBLE self.set_header(msg, 'Date', self.message_date, encode=False) self.set_header(msg, 'Message-ID', self.message_id(), encode=False) @@ -255,8 +264,11 @@ class Message(object): mail_to = self._mail_to and self.encode_name_header(*self._mail_to[0]) or None self.set_header(msg, 'To', mail_to, encode=False) + msgrel = SafeMIMEMultipart('related') + msg.attach(msgrel) + msgalt = SafeMIMEMultipart('alternative') - msg.attach(msgalt) + msgrel.attach(msgalt) _text = self.text_body _html = self.html_body @@ -275,34 +287,23 @@ class Message(object): msgalt.attach(msghtml) for f in self.attachments: - msgfile = f.mime - if msgfile: - msg.attach(msgfile) + part = f.mime + if part: + if f.is_inline: + msgrel.attach(part) + else: + msg.attach(part) if self.after_build: self.after_build(self, msg) return msg - def message(self, message_cls=None): - msg = self._build_message(message_cls=message_cls) - if self._dkim_signer: - msg_str = msg.as_string() - dkim_header = self._dkim_signer.get_sign_header(to_bytes(msg_str)) - if dkim_header: - msg._headers.insert(0, dkim_header) - return msg - def as_string(self): - # self.as_string() is not equialent self.message().as_string() - # self.as_string() gets one less message-to-string conversions for dkim - msg = self._build_message() - r = msg.as_string() - if self._dkim_signer: - dkim_header = self._dkim_signer.get_sign(to_bytes(r)) - if dkim_header: - r = dkim_header + r - return r +class MessageSendMixin(object): + + smtp_pool_factory = ObjectFactory + smtp_cls = SMTPBackend @property def smtp_pool(self): @@ -311,9 +312,6 @@ class Message(object): pool = self._smtp_pool = self.smtp_pool_factory(cls=self.smtp_cls) return pool - def dkim(self, **kwargs): - self._dkim_signer = self.dkim_cls(**kwargs) - def send(self, to=None, set_mail_to=True, @@ -361,7 +359,7 @@ class Message(object): from_addr = self._mail_from[1] if not from_addr: - raise ValueError('No from-addr') + raise ValueError('No "from" addr') params = dict(from_addr=from_addr, to_addrs=[to_addr, ], @@ -376,6 +374,105 @@ class Message(object): return response[0] +class MessageTransformerMixin(object): + + transformer_cls = None + + def create_transformer(self, **kw): + cls = self.transformer_cls + if cls is None: + from emails.transformer import MessageTransformer + cls = MessageTransformer + + self._transformer = cls(message=self, **kw) + return self._transformer + + def destroy_transformer(self): + self._transformer = None + + @property + def transformer(self): + t = getattr(self, '_transformer', None) + if t is None: + t = self.create_transformer() + return t + + +class Message(BaseMessage, MessageSendMixin, MessageTransformerMixin): + """ + Email message with: + - DKIM signer + - smtp send + - Message.transformer object + """ + + dkim_cls = DKIMSigner + + def __init__(self, **kwargs): + BaseMessage.__init__(self, **kwargs) + self._dkim_signer = None + self.after_build = None + + def dkim(self, **kwargs): + self._dkim_signer = self.dkim_cls(**kwargs) + + def set_html(self, **kw): + # When html set, remove old transformer + self.destroy_transformer() + super(Message, self).set_html(**kw) + + def as_message(self, message_cls=None): + msg = self._build_message(message_cls=message_cls) + if self._dkim_signer: + msg_str = msg.as_string() + dkim_header = self._dkim_signer.get_sign_header(to_bytes(msg_str)) + if dkim_header: + msg._headers.insert(0, dkim_header) + return msg + + message = as_message + + def as_string(self): + # self.as_string() is not equialent self.message().as_string() + # self.as_string() gets one less message-to-string conversions for dkim + msg = self._build_message() + r = msg.as_string() + if self._dkim_signer: + dkim_header = self._dkim_signer.get_sign(to_bytes(r)) + if dkim_header: + r = dkim_header + r + return r + + def html(**kwargs): return Message(**kwargs) + +class DjangoMessageProxy(object): + + """ + Class looks like django.core.mail.EmailMessage for standard django email backend. + + Example usage: + + message = emails.Message(html='...', subject='...', mail_from='robot@company.ltd') + connection = django.core.mail.get_connection() + + message.set_mail_to('somebody@somewhere.net') + connection.send_messages([DjangoMessageProxy(message), ]) + """ + + def __init__(self, message, recipients=None, context=None): + self._message = message + self._recipients = recipients + self._context = context and context.copy() or {} + + self.from_email = message.mail_from[1] + self.encoding = message.charset + + def recipients(self): + return self._recipients or [r[1] for r in self._message.mail_to] + + def message(self): + self._message.render(**self._context) + return self._message.message() diff --git a/emails/smtp/backend.py b/emails/smtp/backend.py index 31098ab..c8581f3 100644 --- a/emails/smtp/backend.py +++ b/emails/smtp/backend.py @@ -1,11 +1,10 @@ # encoding: utf-8 from __future__ import unicode_literals -__all__ = [ 'SMTPSender' ] +__all__ = ['SMTPBackend'] import smtplib import logging -import threading from functools import wraps from .client import SMTPResponse, SMTPClientWithResponse, SMTPClientWithResponse_SSL @@ -31,25 +30,19 @@ class SMTPBackend: def __init__(self, - user=None, - password=None, ssl=False, - tls=False, - debug=False, fail_silently=True, **kwargs): self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls - self.debug = debug + self.ssl = ssl - self.tls = tls + self.tls = kwargs.get('tls') if self.ssl and self.tls: raise ValueError( "ssl/tls are mutually exclusive, so only set " "one of those settings to True.") - self.user = user - self.password = password if 'timeout' not in kwargs: kwargs['timeout'] = self.DEFAULT_SOCKET_TIMEOUT self.smtp_cls_kwargs = kwargs @@ -59,10 +52,8 @@ class SMTPBackend: self.fail_silently = fail_silently self.connection = None #self.local_hostname=DNS_NAME.get_fqdn() - self._lock = threading.RLock() def open(self): - #logger.debug('SMTPSender _connect') if self.connection is None: self.connection = self.smtp_cls(parent=self, **self.smtp_cls_kwargs) self.connection.initialize() @@ -83,7 +74,6 @@ class SMTPBackend: finally: self.connection = None - def make_response(self, exception=None): return self.response_cls(host=self.host, port=self.port, exception=exception) @@ -105,21 +95,17 @@ class SMTPBackend: def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]): - if not to_addrs: return False + if not to_addrs: + return False if not isinstance(to_addrs, (list, tuple)): to_addrs = [to_addrs, ] - #from_addr = sanitize_address(from_addr, email_message.encoding) - #to_addrs = [sanitize_address(addr, email_message.encoding) for addr in to_addrs] - #message = email_message.message() - #charset = message.get_charset().get_output_charset() if message.get_charset() else 'utf-8' - try: self.open() except (IOError, smtplib.SMTPException) as e: logger.exception("Error connecting smtp server") - response = self.make_response(exception = e) + response = self.make_response(exception=e) if not self.fail_silently: response.raise_if_needed() return [response, ] @@ -133,7 +119,6 @@ class SMTPBackend: rcpt_options=rcpt_options) if not self.fail_silently: - [ r.raise_if_needed() for r in response ] + [r.raise_if_needed() for r in response] return response - diff --git a/emails/smtp/client.py b/emails/smtp/client.py index ab492a4..47fde2e 100644 --- a/emails/smtp/client.py +++ b/emails/smtp/client.py @@ -1,10 +1,8 @@ # encoding: utf-8 - -__all__ = [ 'SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL' ] +__all__ = ['SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL'] from smtplib import _have_ssl, SMTP import smtplib - import logging logger = logging.getLogger(__name__) @@ -17,7 +15,6 @@ class SMTPResponse(object): self.ssl = ssl self.responses = [] self.exception = exception - #self.complete = False self.success = None self.from_addr = None self.esmtp_opts = None @@ -28,7 +25,7 @@ class SMTPResponse(object): self.last_command = None def set_status(self, command, code, text): - self.responses.append( [command, code, text] ) + self.responses.append([command, code, text]) self.status_code = code self.status_text = text self.last_command = command @@ -36,7 +33,7 @@ class SMTPResponse(object): def set_exception(self, exc): self.exception = exc - def raise_if_needed(): + def raise_if_needed(self): if self.exception: raise self.exception @@ -49,9 +46,6 @@ class SMTPResponse(object): self.status_text.__repr__()) -#class SMTPCommandsLog: - - class SMTPClientWithResponse(SMTP): def __init__(self, parent, **kwargs): @@ -59,19 +53,17 @@ class SMTPClientWithResponse(SMTP): self.make_response = parent.make_response self._last_smtp_response = (None, None) self.tls = kwargs.pop('tls', False) - self.debug = kwargs.pop('debug', False) + self.ssl = kwargs.pop('ssl', False) + self.debug = kwargs.pop('debug', 0) + self.set_debuglevel(self.debug) self.user = kwargs.pop('user', None) self.password = kwargs.pop('password', None) SMTP.__init__(self, **kwargs) self.initialize() def initialize(self): - if self.debug: - self.set_debuglevel(1) if self.tls: - self.ehlo() self.starttls() - self.ehlo() if self.user: self.login(user=self.user, password=self.password) self.ehlo_or_helo_if_needed() @@ -92,7 +84,6 @@ class SMTPClientWithResponse(SMTP): self._last_smtp_response = (code, msg) return code, msg - def _send_one_mail(self, from_addr, to_addr, msg, mail_options=[], rcpt_options=[]): esmtp_opts = [] @@ -140,18 +131,17 @@ class SMTPClientWithResponse(SMTP): return response def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]): - # Send one email and returns one response if not to_addrs: - raise StopIteration + return [] assert isinstance(to_addrs, (list, tuple)) if len(to_addrs)>1: logger.warning('Beware: emails.smtp.client.SMTPClientWithResponse.sendmail sends full message to each email') - return [ self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \ - for to_addr in to_addrs ] + return [self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \ + for to_addr in to_addrs] @@ -160,18 +150,34 @@ if _have_ssl: from smtplib import SMTP_SSL import ssl - class SMTPClientWithResponse_SSL(SMTPClientWithResponse, SMTP_SSL): + class SMTPClientWithResponse_SSL(SMTP_SSL, SMTPClientWithResponse): + + def __init__(self, **kw): + args = {} + for k in ('host', 'port', 'local_hostname', 'keyfile', 'certfile', 'timeout'): + if k in kw: + args[k] = kw[k] + SMTP_SSL.__init__(self, **args) + SMTPClientWithResponse.__init__(self, **kw) + + def data(self, msg): + (code, msg) = SMTP.data(self, msg) + self._last_smtp_response = (code, msg) + return code, msg def quit(self): """Closes the connection to the email server.""" try: - super(self, SMTPClientWithResponse_SSL).quit() + SMTPClientWithResponse.quit(self) except (ssl.SSLError, smtplib.SMTPServerDisconnected): # This happens when calling quit() on a TLS connection # sometimes, or when the connection was already disconnected # by the server. self.close() + def sendmail(self, *args, **kw): + return SMTPClientWithResponse.sendmail(self, *args, **kw) + else: class SMTPClientWithResponse_SSL: diff --git a/emails/smtp/factory.py b/emails/smtp/factory.py index f01de0f..9e326f0 100644 --- a/emails/smtp/factory.py +++ b/emails/smtp/factory.py @@ -2,7 +2,7 @@ def simple_dict2str(d): # Simple dict serializer - return ";".join( [ "%s=%s" % (k, v) for (k, v) in d.items() ] ) + return ";".join(["%s=%s" % (k, v) for (k, v) in d.items()]) _serializer = simple_dict2str diff --git a/emails/store/file.py b/emails/store/file.py index 852bd97..80a3985 100644 --- a/emails/store/file.py +++ b/emails/store/file.py @@ -10,8 +10,11 @@ import requests from mimetypes import guess_type from email.mime.base import MIMEBase from email.encoders import encode_base64 +import emails from emails.compat import urlparse from emails.compat import string_types, to_bytes +from emails.utils import fetch_url, encode_header + # class FileNotFound(Exception): # pass @@ -32,6 +35,8 @@ class BaseFile(object): Store base "attachment-file" information. """ + content_id_suffix = '@python.emails' + def __init__(self, **kwargs): """ uri and filename are connected properties. @@ -42,12 +47,11 @@ class BaseFile(object): self.absolute_url = kwargs.get('absolute_url', None) or self.uri self.filename = kwargs.get('filename', None) self.data = kwargs.get('data', None) - self._mime_type = kwargs.get('mime_type', None) - self._headers = kwargs.get('headers', None) - self._content_disposition = kwargs.get('content_disposition', None) - self.subtype = kwargs.get('subtype', None) - self.local_loader = kwargs.get('local_loader', None) - self.id = id + self._mime_type = kwargs.get('mime_type') + self._headers = kwargs.get('headers') + self._content_disposition = kwargs.get('content_disposition', 'attachment') + self.subtype = kwargs.get('subtype') + self.local_loader = kwargs.get('local_loader') def as_dict(self, fields=None): fields = fields or ('uri', 'absolute_url', 'filename', 'data', @@ -119,21 +123,41 @@ class BaseFile(object): content_disposition = property(get_content_disposition, set_content_disposition) + @property + def is_inline(self): + return self.content_disposition == 'inline' + + @is_inline.setter + def is_inline(self, value): + if bool(value): + self.content_disposition = 'inline' + else: + self.content_disposition = 'attachment' + + @property + def content_id(self): + return "{0}{1}".format(self.filename, self.content_id_suffix) + + @staticmethod + def parse_content_id(cls, content_id): + if content_id.endswith(cls.content_id_suffix): + return {'filename': content_id[:-len(cls.content_id_suffix)]} + else: + return None + @property def mime(self): if self.content_disposition is None: return None _mime = getattr(self, '_cached_mime', None) if _mime is None: - filename = str(Header(self.filename, 'utf-8')) - self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1)) + filename_header = encode_header(self.filename) + self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1), name=filename_header) _mime.set_payload(to_bytes(self.data)) encode_base64(_mime) - _mime.add_header('Content-Disposition', - self.content_disposition, - filename=filename) + _mime.add_header('Content-Disposition', self.content_disposition, filename=filename_header) if self.content_disposition == 'inline': - _mime.add_header('Content-ID', '<{0}>'.format(filename)) + _mime.add_header('Content-ID', '<%s>' % self.content_id) return _mime def reset_mime(self): @@ -145,11 +169,9 @@ class BaseFile(object): class LazyHTTPFile(BaseFile): - def __init__(self, fetch_params=None, **kwargs): + def __init__(self, requests_args=None, **kwargs): BaseFile.__init__(self, **kwargs) - self.fetch_params = dict(allow_redirects=True, verify=False) - if fetch_params: - self.fetch_params.update(fetch_params) + self.requests_args = requests_args self._fetched = False def fetch(self): @@ -162,7 +184,7 @@ class LazyHTTPFile(BaseFile): self._data = data return - r = requests.get(self.absolute_url or self.uri, **self.fetch_params) + r = fetch_url(url=self.absolute_url or self.uri, requests_args=self.requests_args) if r.status_code == 200: self._data = r.content self._headers = r.headers diff --git a/emails/store/store.py b/emails/store/store.py index e56ca39..1f563d8 100644 --- a/emails/store/store.py +++ b/emails/store/store.py @@ -18,7 +18,7 @@ class MemoryFileStore(FileStore): if file_cls: self.file_cls = file_cls self._files = OrderedDict() - self._filenames = set() + self._filenames = {} def __contains__(self, k): if isinstance(k, self.file_cls): @@ -48,24 +48,26 @@ class MemoryFileStore(FileStore): if v: filename = v.filename if filename and (filename in self._filenames): - self._filenames.remove(filename) + del self._filenames[filename] del self._files[uri] - def unique_filename(self, filename): + def unique_filename(self, filename, uri=None): - if filename not in self._filenames: - return filename + if filename in self._filenames: + n = 1 + basefilename, ext = splitext(filename) - n = 1 - basefilename, ext = splitext(filename) + while True: + n += 1 + filename = "%s-%d%s" % (basefilename, n, ext) + if filename not in self._filenames: + break + else: + self._filenames[filename] = uri - while True: - n += 1 - filename = "%s-%d%s" % (basefilename, n, ext) - if filename not in self._filenames: - return filename + return filename - def add(self, value): + def add(self, value, replace=False): if isinstance(value, self.file_cls): uri = value.uri @@ -75,24 +77,35 @@ class MemoryFileStore(FileStore): else: raise ValueError("Unknown file type: %s" % type(value)) - self.remove(uri) - value.filename = self.unique_filename(value.filename) - self._filenames.add(value.filename) - self._files[uri] = value + if (uri not in self._files) or replace: + self.remove(uri) + value.filename = self.unique_filename(value.filename, uri=uri) + self._files[uri] = value + return value - def by_uri(self, uri, synonims=None): + def by_uri(self, uri, synonyms=None): r = self._files.get(uri, None) if r: return r - if synonims: - for _uri in synonims: + if synonyms: + for _uri in synonyms: r = self._files.get(_uri, None) if r: return r return None + def by_filename(self, filename): + uri = self._filenames.get(filename) + if uri: + return self.by_uri(uri) + + def by_content_id(self, content_id): + parsed = self.file_cls.parse_content_id(content_id) + if parsed: + return self.by_filename(parsed['filename']) + def __getitem__(self, uri): - return self._files.get(uri, None) + return self.by_uri(uri) or self.by_filename(uri) def __iter__(self): for k in self._files: diff --git a/emails/testsuite/conftest.py b/emails/testsuite/conftest.py index 581bd3b..5993e3f 100644 --- a/emails/testsuite/conftest.py +++ b/emails/testsuite/conftest.py @@ -7,7 +7,7 @@ import logging import threading import os import os.path - +import datetime import pytest @@ -98,10 +98,82 @@ def smtp_server(request): def django_email_backend(request): from django.conf import settings logger.debug('django_email_backend...') - server = smtp_server(request) - settings.configure(EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', - EMAIL_HOST=server.host, EMAIL_PORT=server.port) + settings.configure(EMAIL_BACKEND='django.core.mail.backends.filebased.EmailBackend', + EMAIL_FILE_PATH='tmp-emails') from django.core.mail import get_connection - SETTINGS = {} return get_connection() + +class SMTPTestParams: + + subject_prefix = '[test-python-emails]' + + def __init__(self, from_email=None, to_email=None, defaults=None, **kw): + params = {} + params.update(defaults or {}) + params.update(kw) + params['debug'] = 1 + params['timeout'] = 15 + self.params = params + + self.from_email = from_email + self.to_email = to_email + + def patch_message(self, message): + # Some SMTP requires from and to emails + + if self.from_email: + message._mail_from = (message._mail_from[0], self.from_email) + + if self.to_email: + message.mail_to = self.to_email + + # TODO: this code breaks template in subject; deal with this + message.subject = " ".join([self.subject_prefix, datetime.datetime.now().strftime('%H:%M:%S'), + message.subject]) + + def __str__(self): + return u'SMTPTestParams(host={0}, port={1}, user={2})'.format(self.params.get('host'), + self.params.get('port'), + self.params.get('user')) + + + +@pytest.fixture(scope='module') +def smtp_servers(request): + + r = [] + + """ + r.append(SMTPTestParams(from_email='drlavr@yandex.ru', + to_email='drlavr@yandex.ru', + fail_silently=False, + **{'host': 'mx.yandex.ru', 'port': 25, 'ssl': False})) + + r.append(SMTPTestParams(from_email='drlavr+togmail@yandex.ru', + to_email='s.lavrinenko@gmail.com', + fail_silently=False, + **{'host': 'gmail-smtp-in.l.google.com', 'port': 25, 'ssl': False})) + + + r.append(SMTPTestParams(from_email='drlavr@yandex.ru', + to_email='s.lavrinenko@me.com', + fail_silently=False, + **{'host': 'mx3.mail.icloud.com', 'port': 25, 'ssl': False})) + """ + + r.append(SMTPTestParams(from_email='drlavr@yandex.ru', + to_email='lavr@outlook.com', + fail_silently=False, + **{'host': 'mx1.hotmail.com', 'port': 25, 'ssl': False})) + + try: + from .local_smtp_settings import SMTP_SETTINGS_WITH_AUTH, FROM_EMAIL, TO_EMAIL + r.append(SMTPTestParams(from_email=FROM_EMAIL, + to_email=TO_EMAIL, + fail_silently=False, + **SMTP_SETTINGS_WITH_AUTH)) + except ImportError: + pass + + return r \ No newline at end of file diff --git a/emails/testsuite/smtp/test_django_integrations.py b/emails/testsuite/django_/test_django_integrations.py similarity index 62% rename from emails/testsuite/smtp/test_django_integrations.py rename to emails/testsuite/django_/test_django_integrations.py index 6f072d0..bfe1618 100644 --- a/emails/testsuite/smtp/test_django_integrations.py +++ b/emails/testsuite/django_/test_django_integrations.py @@ -1,6 +1,7 @@ # encoding: utf-8 from __future__ import unicode_literals import emails +import emails.message def test_send_via_django_backend(django_email_backend): @@ -9,7 +10,7 @@ def test_send_via_django_backend(django_email_backend): Send email via django's email backend. `django_email_backend` defined in conftest.py """ - message_params = {'html':'

Test from python-emails', + message_params = {'html': '

Test from python-emails', 'mail_from': 's@lavr.me', 'mail_to': 's.lavrinenko@gmail.com', 'subject': 'Test from python-emails'} @@ -22,3 +23,12 @@ def test_send_via_django_backend(django_email_backend): headers = {'Reply-To': 'another@example.com'}) backend.send_messages([email, ]) + +def test_django_message_proxy(django_email_backend): + + message_params = {'html': '

Test from python-emails', + 'mail_from': 's@lavr.me', + 'mail_to': 's.lavrinenko@gmail.com', + 'subject': 'Test from python-emails'} + msg = emails.html(**message_params) + django_email_backend.send_messages([emails.message.DjangoMessageProxy(msg), ]) diff --git a/emails/testsuite/loader/data/html_import/oldornament.zip b/emails/testsuite/loader/data/html_import/oldornament.zip index 1e3802dbe014870d3d8b25574894efe152f0780b..2e18f660b11aa49dfb439452f35f432dea05cd34 100644 GIT binary patch delta 39716 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 delta 47814 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 VLp!b$Sm1xY@Z6Qa4j!IC{{;|t=d1t# diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/left_sidebar.html b/emails/testsuite/loader/data/html_import/oldornament/html/left_sidebar.html deleted file mode 100755 index 4a41f96..0000000 --- a/emails/testsuite/loader/data/html_import/oldornament/html/left_sidebar.html +++ /dev/null @@ -1,152 +0,0 @@ - - - -SET-3-old-ornament - - - - - - - - -
- - - - - - - - - - - - - - - - - -
-

You're receiving this newsletter because you bought widgets from us.
- Having trouble reading this email? View it in your browser. Not interested anymore? Unsubscribe.

-
- - - - - - - - - - - -
- - - - - - - - - - -
- - - - -
-

Dear Simon,

-

Welcome to lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aliquam molestie quam vitae mi congue tristique. Aliquam lectus orci, adipiscing et, sodales ac. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien.

-

Regards, ABC Widgets

-
-
-
- - - - - - - - - - - - -
-

Lorem Ipsum Dolor Sit Amet

-

- - - - -
-

Cras purus. Nunc rhoncus. Pellentesque semper. Donec imperdiet accumsan felis. Proin eget mi. Sed at est. Aliquam lectus orci, adipiscing et, sodales ac celeste. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien auctor arcu

- -

Fermentum Quam Etur Lectus

-

- - - - -
-

Suspendisse potenti--Fusce eu ante in sapien vestibulum sagittis. Cras purus. Nunc rhoncus. Donec imperdiet, nibh sit amet pharetra placerat, tortor purus condimentum lectus, at dignissim nibh velit vitae sem. Nunc condimentum blandit tortorphasellus neque vitae purus.

- -

Lorem Ipsum Dolor Sit Amet

-

- - - - -
-

Cras purus. Nunc rhoncus. Pellentesque semper. Donec imperdiet accumsan felis. Proin eget mi. Sed at est. Aliquam lectus orci, adipiscing et, sodales ac celeste. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien auctor arcu

-
-

Forward this issue

-

-

Do you know someone who might be interested in receiving this monthly newsletter?

-

forward

- -

Unsubscribe

-

-

You're receiving this newsletter because you signed up for the ABC Widget Newsletter.

-

unsubscribe

- -

Contact us

-

-

123 Some Street
- City, State
- 99999
- (147) 789 7745
- www.abcwidgets.com
- info@abcwidgets.com

-
Back to top
-
-
- - diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/right_sidebar.html b/emails/testsuite/loader/data/html_import/oldornament/html/right_sidebar.html deleted file mode 100755 index 2e0dfa0..0000000 --- a/emails/testsuite/loader/data/html_import/oldornament/html/right_sidebar.html +++ /dev/null @@ -1,153 +0,0 @@ - - - -SET-3-old-ornament - - - - - - - - -
- - - - - - - - - - - - - - - - - -
-

You're receiving this newsletter because you bought widgets from us.
- Having trouble reading this email? View it in your browser. Not interested anymore? Unsubscribe.

-
- - - - - - - - - - - -
- - - - - - - - - - -
- - - - -
-

Dear Simon,

-

Welcome to lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aliquam molestie quam vitae mi congue tristique. Aliquam lectus orci, adipiscing et, sodales ac. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien.

-

Regards, ABC Widgets

-
-
-
- - - - - - - - - - - - - -
-

Forward this issue

-

-

Do you know someone who might be interested in receiving this monthly newsletter?

-

forward

- -

Unsubscribe

-

-

You're receiving this newsletter because you signed up for the ABC Widget Newsletter.

-

unsubscribe

- -

Contact us

-

-

123 Some Street
- City, State
- 99999
- (147) 789 7745
- www.abcwidgets.com
- info@abcwidgets.com

-
-

Lorem Ipsum Dolor Sit Amet

-

- - - - -
-

Cras purus. Nunc rhoncus. Pellentesque semper. Donec imperdiet accumsan felis. Proin eget mi. Sed at est. Aliquam lectus orci, adipiscing et, sodales ac celeste. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien auctor arcu

- -

Fermentum Quam Etur Lectus

-

- - - - -
-

Suspendisse potenti--Fusce eu ante in sapien vestibulum sagittis. Cras purus. Nunc rhoncus. Donec imperdiet, nibh sit amet pharetra placerat, tortor purus condimentum lectus, at dignissim nibh velit vitae sem. Nunc condimentum blandit tortorphasellus neque vitae purus.

- -

Lorem Ipsum Dolor Sit Amet

-

- - - - -
-

Cras purus. Nunc rhoncus. Pellentesque semper. Donec imperdiet accumsan felis. Proin eget mi. Sed at est. Aliquam lectus orci, adipiscing et, sodales ac celeste. Ut dictum velit nec est. Quisque posuere, purus sit amet malesuada blandit, sapien sapien auctor arcu

-
 Back to top
-
-
- - diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/arrow.png b/emails/testsuite/loader/data/html_import/oldornament/images/arrow.png similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/arrow.png rename to emails/testsuite/loader/data/html_import/oldornament/images/arrow.png diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/banner-bottom.png b/emails/testsuite/loader/data/html_import/oldornament/images/banner-bottom.png similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/banner-bottom.png rename to emails/testsuite/loader/data/html_import/oldornament/images/banner-bottom.png diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/banner-middle.gif b/emails/testsuite/loader/data/html_import/oldornament/images/banner-middle.gif similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/banner-middle.gif rename to emails/testsuite/loader/data/html_import/oldornament/images/banner-middle.gif diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/banner-top.gif b/emails/testsuite/loader/data/html_import/oldornament/images/banner-top.gif similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/banner-top.gif rename to emails/testsuite/loader/data/html_import/oldornament/images/banner-top.gif diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/bg-all.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/bg-all.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/bg-all.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/bg-all.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/bg-content.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/bg-content.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/bg-content.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/bg-content.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/bg-main.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/bg-main.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/bg-main.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/bg-main.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/divider.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/divider.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/divider.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/divider.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/divider2.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/divider2.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/divider2.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/divider2.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/flourish.png b/emails/testsuite/loader/data/html_import/oldornament/images/flourish.png similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/flourish.png rename to emails/testsuite/loader/data/html_import/oldornament/images/flourish.png diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/img01.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/img01.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/img01.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/img01.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/img02.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/img02.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/img02.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/img02.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/img03.jpg b/emails/testsuite/loader/data/html_import/oldornament/images/img03.jpg similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/img03.jpg rename to emails/testsuite/loader/data/html_import/oldornament/images/img03.jpg diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/images/spacer.gif b/emails/testsuite/loader/data/html_import/oldornament/images/spacer.gif similarity index 100% rename from emails/testsuite/loader/data/html_import/oldornament/html/images/spacer.gif rename to emails/testsuite/loader/data/html_import/oldornament/images/spacer.gif diff --git a/emails/testsuite/loader/data/html_import/oldornament/html/full_width.html b/emails/testsuite/loader/data/html_import/oldornament/index.html similarity index 99% rename from emails/testsuite/loader/data/html_import/oldornament/html/full_width.html rename to emails/testsuite/loader/data/html_import/oldornament/index.html index d4eadc2..7aea0d8 100755 --- a/emails/testsuite/loader/data/html_import/oldornament/html/full_width.html +++ b/emails/testsuite/loader/data/html_import/oldornament/index.html @@ -156,7 +156,7 @@ City, State
99999
(147) 789 7745
- www.abcwidgets.com
+ www.abcwidgets.com
info@abcwidgets.com

diff --git a/emails/testsuite/loader/test_loader.py b/emails/testsuite/loader/test_loader.py deleted file mode 100644 index 72c92ef..0000000 --- a/emails/testsuite/loader/test_loader.py +++ /dev/null @@ -1,208 +0,0 @@ -# encoding: utf-8 -from __future__ import unicode_literals -import emails -from emails.loader.stylesheets import StyledTagWrapper -from emails.compat import to_unicode - -import lxml -import lxml.etree - -import os.path - - -def test_tagwithstyle(): - content = """
""" - tree = lxml.etree.HTML(content, parser=lxml.etree.HTMLParser()) - t = None - for el in tree.iter(): - if el.get('style'): - t = StyledTagWrapper(el) - - assert len(list(t.uri_properties())) == 1 - - -def normalize_html(s): - return "".join(to_unicode(s).split()) - - -def test_insert_style(): - html = """ """ - tree = lxml.etree.HTML(html, parser=lxml.etree.HTMLParser()) - # print __name__, "test_insert_style step1: ", lxml.etree.tostring(tree, encoding='utf-8', method='html') - emails.loader.helpers.add_body_stylesheet(tree, - element_cls=lxml.etree.Element, - tag="body", - cssText="") - - #print __name__, "test_insert_style step2: ", lxml.etree.tostring(tree, encoding='utf-8', method='html') - - new_document = emails.loader.helpers.set_content_type_meta(tree, element_cls=lxml.etree.Element) - if tree != new_document: - # document may be updated here (i.e. html tag added) - tree = new_document - - html = normalize_html(lxml.etree.tostring(tree, encoding='utf-8', method='html')) - RESULT_HTML = normalize_html( - '' - ' ' - ' ') - assert html == RESULT_HTML, "Invalid html expected: %s, got: %s" % (RESULT_HTML.__repr__(), html.__repr__()) - - -def test_all_images(): - # Check if we load images from CSS: - styles = emails.loader.stylesheets.PageStylesheets() - styles.append(text="p {background: url(3.png);}") - assert len(styles.uri_properties) == 1 - - - # Check if we load all images from html: - HTML1 = """ """ - loader = emails.loader.from_string(html=HTML1) - # should be 3 image_link object - assert len(list(loader.iter_image_links())) == 3 - - # should be 3 files in filestore - files = set(loader.filestore.keys()) - assert len(files) == 3 - - # Check if changing links affects result html: - for obj in loader.iter_image_links(): - obj.link = "prefix_" + obj.link - - result_html = normalize_html(loader.html) - VALID_RESULT = normalize_html("""""" - """""" - """ """) - - assert result_html == VALID_RESULT, "Invalid html expected: %s, got: %s" % ( - result_html.__repr__(), VALID_RESULT.__repr__()) - - -def test_load_local_directory(): - ROOT = os.path.dirname(__file__) - - colordirect_html = "data/html_import/colordirect/html/left_sidebar.html" - colordirect_loader = emails.loader.from_file(os.path.join(ROOT, colordirect_html)) - - ALL_FILES = "bg_divider_top.png,bullet.png,img.png,img_deco_bottom.png,img_email.png," \ - "bg_email.png,ico_lupa.png,img_deco.png".split(',') - ALL_FILES = set(["images/" + n for n in ALL_FILES]) - - files = set(colordirect_loader.filestore.keys()) - - not_attached = ALL_FILES - files - - assert len(not_attached) == 0, "Not attached files found: %s" % not_attached - - for fn in ( "data/html_import/colordirect/html/full_width.html", - "data/html_import/oldornament/html/full_width.html" - ): - filename = os.path.join(ROOT, fn) - print(fn) - loader = emails.loader.from_file(filename) - print(loader.html) - - -def test_load_http(): - URLs = [ - 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html', - 'https://github.com/lavr/python-emails', - 'http://cnn.com', - 'http://yandex.com', - 'http://yahoo.com', - 'http://www.smashingmagazine.com/' - ] - - for url in URLs[:1]: - # Load some sites. - # Loader just shouldn't throw exception - emails.loader.from_url(url) - - -def test_load_zip(): - ROOT = os.path.dirname(__file__) - filename = os.path.join(ROOT, "data/html_import/oldornament.zip") - loader = emails.loader.from_zip(open(filename, 'rb')) - assert len(list(loader.filestore.keys())) >= 13 - assert "SET-3-old-ornament" in loader.html - - -def _do_inline_css(html, css, save_to_file=None, pretty_print=False): - inliner = emails.loader.cssinliner.CSSInliner() - inliner.DEBUG = True - inliner.add_css(css) - document = inliner.transform_html(html) - r = lxml.etree.tostring(document, pretty_print=pretty_print) - if save_to_file: - open(save_to_file, 'wb').write(r) - return r - - -def test_unmergeable_css(): - HTML = "b" - CSS = "a:visited {color: red;}" - r = _do_inline_css(HTML, CSS) # , save_to_file='_result.html') - print(r) - - -def test_commons_css_inline(): - tmpl = '''style test%s''' - - HTML = tmpl % ''' -

Style example 1

-

<p>

-

<p> with inline style: "color: red"

-

p#x with inline style: "color: red"

-
a <div> green?
-
#y pink?
- ''' - - CSS = r''' - * { - margin: 0; - } - body { - color: blue !important; - font: normal 100% sans-serif; - } - p { - c\olor: green; - font-size: 2em; - } - p#x { - color: black !important; - } - div { - color: green; - font-size: 1.5em; - } - #y { - color: #f0f; - } - .cssutils { - font: 1em "Lucida Console", monospace; - border: 1px outset; - padding: 5px; - } - ''' - - VALID_RESULT = normalize_html(""" - - style test - - -

Style example 1

-

<p>

-

<p> with inline style: "color: red"

-

p#x with inline style: "color: red"

-
a <div> green?
-
#y pink?
- -""") - - result = normalize_html(_do_inline_css(HTML, CSS, pretty_print=True)) # , save_to_file='_result.html') - assert VALID_RESULT.strip() == result.strip(), "Invalid html got: %s, expected: %s" % ( - result.__repr__(), VALID_RESULT.__repr__()) - - diff --git a/emails/testsuite/loader/test_loaders.py b/emails/testsuite/loader/test_loaders.py new file mode 100644 index 0000000..bf603fa --- /dev/null +++ b/emails/testsuite/loader/test_loaders.py @@ -0,0 +1,106 @@ +# encoding: utf-8 +from __future__ import unicode_literals +import glob +import os.path +import email +from requests import ConnectionError +import emails +import emails.loader +import emails.transformer +from emails.loader.local_store import MsgLoader + +ROOT = os.path.dirname(__file__) + +BASE_URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/oldornament' + + +def _get_messages(**kw): + # All loaders loads same data + yield emails.loader.from_url(BASE_URL + '/index.html', **kw) + yield emails.loader.from_file(os.path.join(ROOT, "data/html_import/oldornament/index.html"), **kw) + yield emails.loader.from_zip(open(os.path.join(ROOT, "data/html_import/oldornament.zip"), 'rb'), **kw) + + +def normalize_html(s): + def _remove_base_url(src, **kw): + if src.startswith(BASE_URL): + return src[len(BASE_URL)+1:] + else: + return src + + # Use Transformer not for test, just to walk tree + t = emails.transformer.Transformer(html=s) + t.apply_to_links(_remove_base_url) + t.apply_to_images(_remove_base_url) + return t.to_string() + + +def all_equals(seq): + iseq = iter(seq) + first = next(iseq) + return all(x == first for x in iseq) + + +def test_loaders(): + + messages = list(_get_messages()) + + # Check loaded images + for m in messages: + assert len(m.attachments.keys()) == 13 + + valid_filenames = ['arrow.png', 'banner-bottom.png', 'banner-middle.gif', 'banner-top.gif', 'bg-all.jpg', + 'bg-content.jpg', 'bg-main.jpg', 'divider.jpg', 'flourish.png', 'img01.jpg', 'img02.jpg', + 'img03.jpg', 'spacer.gif'] + assert sorted([a.filename for a in messages[0].attachments]) == sorted(valid_filenames) + assert len(messages[0].attachments.by_filename('arrow.png').data) == 484 + + # Simple html content check + htmls = [normalize_html(m.html) for m in messages] + assert 'Lorem Ipsum Dolor Sit Amet' in htmls[0] + assert all_equals(htmls) + + +def _test_external_urls(): + + # Load some real sites with complicated html and css. + # Test loader don't throw any exception. + + for url in [ + 'https://github.com/lavr/python-emails', + 'http://yandex.com', + 'http://www.smashingmagazine.com/' + ]: + try: + emails.loader.from_url(url) + except ConnectionError: + # Nevermind if external site does not respond + pass + + +def test_msgloader(): + + data = {'charset': 'utf-8', + 'subject': 'Что-то по-русски', + 'mail_from': ('Максим Иванов', 'ivanov@ya.ru'), + 'mail_to': ('Полина Сергеева', 'polina@mail.ru'), + 'html': '

Привет!

В первых строках...', + 'text': 'Привет!\nВ первых строках...', + 'headers': {'X-Mailer': 'python-emails'}, + 'attachments': [{'data': 'aaa', 'filename': 'Event.ics'},], + 'message_id': 'message_id'} + + msg = emails.Message(**data).as_string() + loader = MsgLoader(msg=msg) + loader._parse_msg() + assert 'Event.ics' in loader.list_files() + assert loader['__index.html'] == data['html'] + assert loader['__index.txt'] == data['text'] + + +def _test_mass_msgloader(): + ROOT = os.path.dirname(__file__) + for filename in glob.glob(os.path.join(ROOT, "data/msg/*.eml")): + msg = email.message_from_string(open(filename).read()) + msgloader = MsgLoader(msg=msg) + msgloader._parse_msg() \ No newline at end of file diff --git a/emails/testsuite/message/helpers.py b/emails/testsuite/message/helpers.py index 795b413..a0c8cb8 100644 --- a/emails/testsuite/message/helpers.py +++ b/emails/testsuite/message/helpers.py @@ -1,49 +1,31 @@ # coding: utf-8 from __future__ import unicode_literals - -import logging import os -from emails.loader import cssinliner import emails from emails.compat import StringIO from emails.template import JinjaTemplate -from emails.compat import NativeStringIO, to_bytes + +TO_EMAIL = 'jbrown@hotmail.tld' +FROM_EMAIL = 'robot@company.tld' TRAVIS_CI = os.environ.get('TRAVIS') HAS_INTERNET_CONNECTION = not TRAVIS_CI -def common_email_data(**kwargs): - data = {'charset': 'utf-8', - 'subject': 'Что-то по-русски', - 'mail_from': ('Максим Иванов', 'ivanov@ya.ru'), - 'mail_to': ('Полина Сергеева', 'polina@mail.ru'), - 'html': '

Привет!

В первых строках...', - 'text': 'Привет!\nВ первых строках...', - 'headers': {'X-Mailer': 'python-emails'}, - 'attachments': [{'data': 'aaa', 'filename': 'Event.ics'}, - {'data': StringIO('bbb'), 'filename': 'map.png'}], - 'message_id': emails.MessageID()} - - if kwargs: - data.update(kwargs) - - return data - - -def _email_data(**kwargs): +def common_email_data(**kw): T = JinjaTemplate data = {'charset': 'utf-8', - 'subject': T('Hello, {{name}}'), - 'mail_from': ('Максим Иванов', 'sergei-nko@mail.ru'), - 'mail_to': ('Полина Сергеева', 'sergei-nko@mail.ru'), - 'html': T('

Привет, {{name}}!

В первых строках...'), - 'text': T('Привет, {{name}}!\nВ первых строках...'), + 'subject': T('[python-emails test] Olá {{name}}'), + 'mail_from': ('LÖVÅS HÅVET', FROM_EMAIL), + 'mail_to': ('Pestävä erillään', TO_EMAIL), + 'html': T('

Olá {{name}}!

O Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'), + 'text': T('Olá, {{name}}!\nO Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'), 'headers': {'X-Mailer': 'python-emails'}, + 'message_id': emails.MessageID(), 'attachments': [ - {'data': 'aaa', 'filename': 'Event.ics', 'content_disposition': 'attachment'}, - {'data': 'bbb', 'filename': 'Карта.png', 'content_disposition': 'attachment'} + {'data': 'aaa', 'filename': 'κατάσχεση.ics'}, + {'data': 'bbb', 'filename': 'map.png'} ]} - if kwargs: - data.update(kwargs) - return data \ No newline at end of file + if kw: + data.update(kw) + return data diff --git a/emails/testsuite/message/test_dkim.py b/emails/testsuite/message/test_dkim.py index 2e8f025..7b09597 100644 --- a/emails/testsuite/message/test_dkim.py +++ b/emails/testsuite/message/test_dkim.py @@ -1,13 +1,7 @@ # coding: utf-8 from __future__ import unicode_literals - -import logging import os - -from emails.loader import cssinliner import emails -from emails.compat import StringIO -from emails.template import JinjaTemplate from emails.compat import NativeStringIO, to_bytes diff --git a/emails/testsuite/message/test_message.py b/emails/testsuite/message/test_message.py index d65d9d3..fd59da4 100644 --- a/emails/testsuite/message/test_message.py +++ b/emails/testsuite/message/test_message.py @@ -1,12 +1,9 @@ # coding: utf-8 -from __future__ import unicode_literals +from __future__ import unicode_literals, print_function import emails -from emails.compat import StringIO -from emails.template import JinjaTemplate -from emails.compat import NativeStringIO, to_bytes - -from .helpers import TRAVIS_CI, HAS_INTERNET_CONNECTION, _email_data, common_email_data +from emails.compat import to_unicode +from .helpers import common_email_data def test_message_build(): @@ -18,7 +15,6 @@ def test_message_build(): def test_property_works(): m = emails.Message(subject='A') assert m._subject == 'A' - m.subject = 'C' assert m._subject == 'C' @@ -34,7 +30,9 @@ def test_after_build(): m = emails.Message(**kwargs) m.after_build = my_after_build - assert AFTER_BUILD_HEADER in m.as_string() + s = m.as_string() + print("type of message.as_string() is {0}".format(type(s))) + assert AFTER_BUILD_HEADER in to_unicode(s, 'utf-8') # TODO: more tests here diff --git a/emails/testsuite/message/test_send.py b/emails/testsuite/message/test_send.py index 1308329..c990bc9 100644 --- a/emails/testsuite/message/test_send.py +++ b/emails/testsuite/message/test_send.py @@ -1,79 +1,40 @@ # coding: utf-8 from __future__ import unicode_literals - import logging -import os -from emails.loader import cssinliner import emails -from emails.compat import StringIO -from emails.template import JinjaTemplate -from emails.compat import NativeStringIO, to_bytes +import emails.loader -from .helpers import TRAVIS_CI, HAS_INTERNET_CONNECTION, _email_data, common_email_data +from .helpers import HAS_INTERNET_CONNECTION, common_email_data -try: - from local_settings import SMTP_SERVER, SMTP_PORT, SMTP_SSL, SMTP_USER, SMTP_PASSWORD - - SMTP_DATA = {'host': SMTP_SERVER, 'port': SMTP_PORT, - 'ssl': SMTP_SSL, 'user': SMTP_USER, 'password': SMTP_PASSWORD, - 'debug': 0} -except ImportError: - SMTP_DATA = None - - -def test_send1(): - URL = 'http://icdn.lenta.ru/images/2013/08/07/14/20130807143836932/top7_597745dde10ef36605a1239b0771ff62.jpg' - data = _email_data() - data['attachments'] = [emails.store.LazyHTTPFile(uri=URL), ] - m = emails.html(**data) - m.render(name='Полина') - assert m.subject == 'Hello, Полина' - if HAS_INTERNET_CONNECTION: - r = m.send(smtp=SMTP_DATA) - - -def test_send3(): - data = _email_data(subject='[test python-emails] email with attachments') +def test_send_attachment(smtp_servers): + """ + Test email with attachment + """ + URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/images/gallery.png' + data = common_email_data(subject='Single attachment', attachments=[emails.store.LazyHTTPFile(uri=URL), ]) m = emails.html(**data) if HAS_INTERNET_CONNECTION: - r = m.send(render={'name': u'Полина'}, smtp=SMTP_DATA) + for d in smtp_servers: + d.patch_message(m) + r = m.send(smtp=d.params) -def test_send2(): - data = _email_data() - loader = emails.loader.HTTPLoader(filestore=emails.store.MemoryFileStore()) - URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html' - loader.load_url(URL, css_inline=True, make_links_absolute=True, update_stylesheet=True) - data['html'] = loader.html - data['attachments'] = loader.attachments_dict - loader.save_to_file('test_send2.html') +def test_send_with_render(smtp_servers): + data = common_email_data(subject='Render with name=John') m = emails.html(**data) - m.render(name='Полина') - if HAS_INTERNET_CONNECTION: - r = m.send(smtp=SMTP_DATA) - r = m.send(to='s.lavrinenko@gmail.com', smtp=SMTP_DATA) + for d in smtp_servers: + d.patch_message(m) + r = m.send(render={'name': u'John'}, smtp=d.params) -def test_send_inline_images(): - data = _email_data() - loader = emails.loader.HTTPLoader(filestore=emails.store.MemoryFileStore()) - URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html' - loader.load_url(URL, css_inline=True, make_links_absolute=True, update_stylesheet=True) - for img in loader.iter_image_links(): - link = img.link - file = loader.filestore.by_uri(link, img.link_history) - img.link = "cid:%s" % file.filename - for file in loader.filestore: - file.content_disposition = 'inline' - data['html'] = loader.html - data['attachments'] = loader.attachments_dict - # loader.save_to_file('test_send_inline_images.html') - m = emails.html(**data) - m.render(name='Полина') - +def test_send_with_inline_images(smtp_servers): + url = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html' + data = common_email_data(subject='Sample html with inline images') + del data['html'] + m = emails.loader.from_url(url=url, message_params=data, images_inline=True) if HAS_INTERNET_CONNECTION: - r = m.send(smtp=SMTP_DATA) - if r.status_code != 250: - logging.error("Error sending email, response=%s" % r) + for d in smtp_servers: + d.patch_message(m) + r = m.send(smtp=d.params) diff --git a/emails/testsuite/loader/test_store.py b/emails/testsuite/store/test_store.py similarity index 68% rename from emails/testsuite/loader/test_store.py rename to emails/testsuite/store/test_store.py index eb3aecf..32ecc90 100644 --- a/emails/testsuite/loader/test_store.py +++ b/emails/testsuite/store/test_store.py @@ -1,13 +1,13 @@ # encoding: utf-8 from __future__ import unicode_literals import emails - +import emails.store def test_lazy_http(): IMG_URL = 'http://lavr.github.io/python-emails/tests/python-logo.gif' f = emails.store.LazyHTTPFile(uri=IMG_URL) assert f.filename == 'python-logo.gif' - assert f.content_disposition is None + assert f.content_disposition == 'attachment' assert len(f.data) == 2549 @@ -20,3 +20,9 @@ def test_store_commons(): for (k, v) in orig_file.items(): assert v == getattr(stored_file, k) +def test_store_unique_name(): + store = emails.store.MemoryFileStore() + f1 = store.add({'uri': '/a/c.gif'}) + assert f1.filename == 'c.gif' + f2 = store.add({'uri': '/a/b/c.gif'}) + assert f2.filename == 'c-2.gif' diff --git a/emails/testsuite/test_readme.py b/emails/testsuite/test_readme.py new file mode 100644 index 0000000..4e3f4c9 --- /dev/null +++ b/emails/testsuite/test_readme.py @@ -0,0 +1,14 @@ +# encoding: utf-8 + +import emails, emails.loader + +def test_loader_example(): + + base_url = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/' + URL = base_url + 'template-widgets.html' + + message = emails.Message.from_loader(loader=emails.loader.from_url(URL), + mail_from=('ABC', 'robot@mycompany.com'), + subject="Newsletter") + + print(message.as_string()) diff --git a/emails/testsuite/test_utils.py b/emails/testsuite/test_utils.py new file mode 100644 index 0000000..3ed7915 --- /dev/null +++ b/emails/testsuite/test_utils.py @@ -0,0 +1,11 @@ +# encoding: utf-8 +from __future__ import unicode_literals +from emails.utils import parse_name_and_email + + +def test_parse_name_and_email(): + assert parse_name_and_email('john@smith.me') == (None, 'john@smith.me') + assert parse_name_and_email('"John Smith" ') == \ + ('John Smith', 'john@smith.me') + assert parse_name_and_email(['John Smith', 'john@smith.me']) == \ + ('John Smith', 'john@smith.me') \ No newline at end of file diff --git a/emails/testsuite/transformer/test_transformer.py b/emails/testsuite/transformer/test_transformer.py new file mode 100644 index 0000000..806e54e --- /dev/null +++ b/emails/testsuite/transformer/test_transformer.py @@ -0,0 +1,42 @@ +# encoding: utf-8 +from __future__ import unicode_literals +from emails.transformer import Transformer + + +def test_image_apply(): + + pairs = [ + ("""

""", + """
"""), + + ("""""", + """"""), + + ("""""", + """
""") + ] + + def func(uri, **kw): + return "A/"+uri + + for before, after in pairs: + t = Transformer(html=before) + t.apply_to_images(func) + assert after in t.to_string() + + + +def test_link_apply(): + + pairs = [ + ("""""", + """"""), + ] + + def func(uri, **kw): + return "A/"+uri + + for before, after in pairs: + t = Transformer(html=before) + t.apply_to_links(func) + assert after in t.to_string() diff --git a/emails/transformer.py b/emails/transformer.py new file mode 100644 index 0000000..a462409 --- /dev/null +++ b/emails/transformer.py @@ -0,0 +1,315 @@ +# encoding: utf-8 +from __future__ import unicode_literals +import posixpath +import os.path +import logging +import re +import warnings +from cssutils import CSSParser +from lxml import etree +from premailer import Premailer +from premailer.premailer import ExternalNotFoundError + +import emails +from emails.compat import urlparse, to_unicode, to_bytes, text_type +from emails.store import MemoryFileStore, LazyHTTPFile +from .loader.local_store import FileNotFound + + +class LocalPremailer(Premailer): + + def __init__(self, html, local_loader=None, **kw): + if 'preserve_internal_links' not in kw: + kw['preserve_internal_links'] = True + self.local_loader = local_loader + super(LocalPremailer, self).__init__(html=html, **kw) + + def _load_external(self, url): + """ + loads an external stylesheet from a remote url or local store + """ + if url.startswith('//'): + # then we have to rely on the base_url + if self.base_url and 'https://' in self.base_url: + url = 'https:' + url + else: + url = 'http:' + url + + if url.startswith('http://') or url.startswith('https://'): + content = self._load_external_url(url) + else: + content = None + + if self.local_loader: + try: + content = self.local_loader.get_source(url) + except FileNotFound: + content = None + + if content is None: + if self.base_url: + return self._load_external(urlparse.urljoin(self.base_url, url)) + else: + raise ExternalNotFoundError(url) + + return content + + +class HTMLParser(object): + + _cdata_regex = re.compile(r'\<\!\[CDATA\[(.*?)\]\]\>', re.DOTALL) + + def __init__(self, html, method="html"): + self._html = html + self._method = method + self._tree = None + + @property + def html(self): + return self._html + + @property + def tree(self): + if self._tree is None: + parser = self._method == 'xml' \ + and etree.XMLParser(ns_clean=False, resolve_entities=False) \ + or etree.HTMLParser() + self._tree = etree.fromstring(self._html.strip(), parser) + return self._tree + + def to_string(self, encoding='utf-8', **kwargs): + out = etree.tostring(self.tree, encoding=encoding, method=self._method, **kwargs).decode(encoding) + if self._method == 'xml': + out = self._cdata_regex.sub( + lambda m: '/**/' % m.group(1), + out + ) + return out + + def apply_to_images(self, func, images=True, backgrounds=True, styles_uri=True): + + def _apply_to_style_uri(style_text, func): + dirty = False + parser = CSSParser().parseStyle(style_text) + for prop in parser.getProperties(all=True): + for value in prop.propertyValue: + if value.type == 'URI': + old_uri = value.uri + new_uri = func(old_uri, element=value) + if new_uri != old_uri: + dirty = True + value.uri = new_uri + if dirty: + return to_unicode(parser.cssText, 'utf-8') + else: + return style_text + + if images: + # Apply to images from IMG tag + for img in self.tree.xpath(".//img"): + if 'src' in img.attrib: + img.attrib['src'] = func(img.attrib['src'], element=img) + + if backgrounds: + # Apply to images from + for item in self.tree.xpath("//@background"): + tag = item.getparent() + tag.attrib['background'] = func(tag.attrib['background'], element=tag) + + if styles_uri: + # Apply to style uri + for item in self.tree.xpath("//@style"): + tag = item.getparent() + tag.attrib['style'] = _apply_to_style_uri(tag.attrib['style'], func=func) + + def apply_to_links(self, func): + # Apply to images from IMG tag + for a in self.tree.xpath(".//a"): + if 'href' in a.attrib: + a.attrib['href'] = func(a.attrib['href'], element=a) + + def add_content_type_meta(self, content_type="text/html", charset="utf-8", element_cls=etree.Element): + + def _get_content_type_meta(head): + content_type_meta = None + for meta in head.find('meta') or []: + http_equiv = meta.get('http-equiv', None) + if http_equiv and (http_equiv.lower() == 'content_type'): + content_type_meta = meta + break + if content_type_meta is None: + content_type_meta = element_cls('meta') + head.append(content_type_meta) + return content_type_meta + + head = self.tree.find('head') + if head is None: + logging.warning('HEAD not found. This should not happen. Skip.') + return + + meta = _get_content_type_meta(head) + meta.set('content', '%s; charset=%s' % (content_type, charset)) + meta.set('http-equiv', "Content-Type") + + +class BaseTransformer(HTMLParser): + + UNSAFE_TAGS = ['script', 'object', 'iframe', 'frame', 'base', 'meta', 'link', 'style'] + + attachment_store_cls = MemoryFileStore + attachment_file_cls = LazyHTTPFile + + def __init__(self, html, local_loader=None, + attachment_store=None, + requests_params=None, method="html", base_url=None): + + HTMLParser.__init__(self, html=html, method=method) + + self.attachment_store = attachment_store if attachment_store is not None else self.attachment_store_cls() + self.local_loader = local_loader + self.base_url = base_url + self.requests_params = requests_params + + def get_absolute_url(self, url): + + if not self.base_url: + return url + + if url.startswith('//'): + if 'https://' in self.base_url: + url = 'https:' + url + else: + url = 'http:' + url + return url + + if not (url.startswith('http://') or url.startswith('https://')): + url = urlparse.urljoin(self.base_url, posixpath.normpath(url)) + + return url + + def _load_attachment_func(self, uri, element=None, **kw): + # + # Load uri from remote url or from local_store + # Return local uri + # + attachment = self.attachment_store.by_uri(uri) + if attachment is None: + attachment = self.attachment_file_cls( + uri=uri, + absolute_url=self.get_absolute_url(uri), + local_loader=self.local_loader, + requests_args=self.requests_params) + self.attachment_store.add(attachment) + return attachment.filename + + def remove_unsafe_tags(self): + for tag in self.UNSAFE_TAGS: + for el in self.tree.xpath(".//%s" % tag): + parent = el.getparent() + if parent is not None: + parent.remove(el) + + def load_and_transform(self, + css_inline=True, + remove_unsafe_tags=True, + make_links_absolute=True, + set_content_type_meta=True, + update_stylesheet=True, + load_images=True, + images_inline=False, + **kw): + + if not make_links_absolute: + # Now we use Premailer that always makes links absolute + warnings.warn("make_links_absolute=False is deprecated.", DeprecationWarning) + + if not css_inline: + # Premailer always makes inline css. + warnings.warn("css_inline=False is deprecated.", DeprecationWarning) + + if update_stylesheet: + # Premailer has no such feature. + warnings.warn("update_stylesheet=True is deprecated.", DeprecationWarning) + + # 1. Premailer make some transformations on self.root tree: + # - load external css and make css inline + # - make absolute href and src if base_url is set + premailer = LocalPremailer(html=self.tree, + local_loader=self.local_loader, + method=self._method, + base_url=self.base_url, + **kw) + premailer.transform() + + # 2. Load linked images and transform links + if load_images: + self.apply_to_images(self._load_attachment_func) + + # 3. Remove unsafe tags is requested + if remove_unsafe_tags: + self.remove_unsafe_tags() + + # 4. Set content-type + if set_content_type_meta: + # TODO: may be remove this ? + self.add_content_type_meta() + + # 5. Make images inline + if load_images and images_inline: + for a in self.attachment_store: + a.is_inline = True + self.synchronize_inline_images() + + def synchronize_inline_images(self, inline_names=None, non_inline_names=None): + """ + Set img src in html for images, marked as "inline" in attachments_store + """ + + if inline_names is None or non_inline_names is None: + + inline_names = {} + non_inline_names = {} + + for a in self.attachment_store: + if a.is_inline: + inline_names[a.filename] = a.content_id + else: + non_inline_names[a.content_id] = a.filename + + def _src_update_func(src, **kw): + if src.startswith('cid:'): + content_id = src[4:] + if content_id in non_inline_names: + return non_inline_names[content_id] + else: + if src in inline_names: + return 'cid:'+inline_names[src] + return src + + self.apply_to_images(_src_update_func) + + +class Transformer(BaseTransformer): + + @staticmethod + def from_message(cls, message, **kw): + return cls(html=message.html, attachment_store=message.attachments, **kw) + + def to_message(self, message=None): + if message is None: + message = emails.Message() + message.html_body = self.to_string() + # TODO: Copy attachments may be. + message._attachments = self.attachment_store + + +class MessageTransformer(BaseTransformer): + + def __init__(self, message, **kw): + self.message = message + params = {'html': message._html, 'attachment_store': message.attachments} + params.update(kw) + BaseTransformer.__init__(self, **params) + + def save(self): + self.message._html = self.to_string() \ No newline at end of file diff --git a/emails/utils.py b/emails/utils.py index 5a6f067..77fc0bf 100644 --- a/emails/utils.py +++ b/emails/utils.py @@ -1,5 +1,8 @@ # encoding: utf-8 from __future__ import unicode_literals +import emails +import requests +from emails.exc import HTTPLoaderError __all__ = ['parse_name_and_email', 'load_email_charsets', 'MessageID'] @@ -190,9 +193,27 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart): def __setitem__(self, name, val): MIMEMultipart.__setitem__(self, name, val) -def test_parse_name_and_email(): - assert parse_name_and_email('john@smith.me') == ('', 'john@smith.me') - assert parse_name_and_email('"John Smith" ') == \ - ('John Smith', 'john@smith.me') - assert parse_name_and_email(['John Smith', 'john@smith.me']) == \ - ('John Smith', 'john@smith.me') + +DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True, + verify=False, timeout=10, + headers={'User-Agent': emails.USER_AGENT}) + + +def fetch_url(url, valid_http_codes=(200, ), requests_args=None): + args = {} + args.update(DEFAULT_REQUESTS_PARAMS) + args.update(requests_args or {}) + r = requests.get(url, **args) + if valid_http_codes and (r.status_code not in valid_http_codes): + raise HTTPLoaderError('Error loading url: %s. HTTP status: %s' % (url, r.status_code)) + return r + + +def encode_header(value, charset='utf-8'): + value = to_unicode(value, charset=charset) + if isinstance(value, string_types): + value = value.rstrip() + _r = Header(value, charset) + return str(_r) + else: + return value \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index e1070e1..120d407 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -3,3 +3,4 @@ lxml chardet python-dateutil requests +premailer diff --git a/requirements/tests-2.6.txt b/requirements/tests-2.6.txt index 6304339..29b771f 100644 --- a/requirements/tests-2.6.txt +++ b/requirements/tests-2.6.txt @@ -1,6 +1,6 @@ --requirement=base.txt +--requirement=tests-base.txt -jinja2 -mako django==1.6 lamson +ordereddict diff --git a/requirements/tests-2.7.txt b/requirements/tests-2.7.txt index ef61698..67385ef 100644 --- a/requirements/tests-2.7.txt +++ b/requirements/tests-2.7.txt @@ -1,6 +1,5 @@ --requirement=base.txt +--requirement=tests-base.txt -jinja2 -mako django -lamson +lamson \ No newline at end of file diff --git a/requirements/tests-3.3.txt b/requirements/tests-3.3.txt index cc678a8..b6d69fd 100644 --- a/requirements/tests-3.3.txt +++ b/requirements/tests-3.3.txt @@ -1,5 +1,4 @@ --requirement=base.txt +--requirement=tests-base.txt -jinja2 -mako -django +django \ No newline at end of file diff --git a/requirements/tests-3.4.txt b/requirements/tests-3.4.txt index cc678a8..b6d69fd 100644 --- a/requirements/tests-3.4.txt +++ b/requirements/tests-3.4.txt @@ -1,5 +1,4 @@ --requirement=base.txt +--requirement=tests-base.txt -jinja2 -mako -django +django \ No newline at end of file diff --git a/requirements/tests-base.txt b/requirements/tests-base.txt new file mode 100644 index 0000000..e729ff8 --- /dev/null +++ b/requirements/tests-base.txt @@ -0,0 +1,3 @@ +jinja2 +mako +pytest \ No newline at end of file diff --git a/scripts/make_rfc822.py b/scripts/make_rfc822.py index f4bbebf..a66452d 100755 --- a/scripts/make_rfc822.py +++ b/scripts/make_rfc822.py @@ -7,13 +7,15 @@ Simple utility that imports html from url ang print generated rfc822 message to Example usage: - $ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html - --subject="Some subject" - --from-name="Sergey Lavrinenko" - --from-email=s@lavr.me - --message-id-domain=localhost - --send-test-email-to=sergei-nko@mail.ru - --smtp-host=mxs.mail.ru + $ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html \ + --subject="Some subject" \ + --from-name="Sergey Lavrinenko" \ + --from-email=s@lavr.me \ + --message-id-domain=localhost \ + --add-header="X-Test-Header: Test" \ + --add-header-imported-from \ + --send-test-email-to=sergei-nko@mail.ru \ + --smtp-host=mxs.mail.ru \ --smtp-port=25 Copyright 2013 Sergey Lavrinenko @@ -32,7 +34,6 @@ from emails.template import JinjaTemplate as T class MakeRFC822: - def __init__(self, options): self.options = options @@ -41,9 +42,14 @@ class MakeRFC822: --add-header "X-Source: AAA" """ r = {} - for s in self.options.add_headers: - (k, v) = s.split(':', 1) - r[k] = v + if self.options.add_headers: + for s in self.options.add_headers: + (k, v) = s.split(':', 1) + r[k] = v + + if self.options.add_header_imported_from: + r['X-Imported-From-URL'] = self.options.url + return r def _get_message(self): @@ -51,23 +57,19 @@ class MakeRFC822: options = self.options if options.message_id_domain: - message_id = emails.utils.MessageID(domain=options.message_id_domain) + message_id = emails.MessageID(domain=options.message_id_domain) else: message_id = None loader = emails.loader.from_url(url=options.url, images_inline=options.inline_images) - - message = emails.Message.from_loader(loader=loader, - headers= self._headers_from_command_line(), #{'X-Imported-From-URL': options.url }, - template_cls=T, - mail_from=(options.from_name, options.from_email), - subject=T(unicode(options.subject, 'utf-8')), - message_id=message_id - ) + headers=self._headers_from_command_line(), + template_cls=T, + mail_from=(options.from_name, options.from_email), + subject=T(unicode(options.subject, 'utf-8')), + message_id=message_id) return message - def _send_test_email(self, message): options = self.options @@ -88,9 +90,10 @@ class MakeRFC822: def _start_batch(self): fn = self.options.batch - if not fn: return None + if not fn: + return None - if fn=='-': + if fn == '-': f = sys.stdin else: f = open(fn, 'rb') @@ -98,16 +101,16 @@ class MakeRFC822: def wrapper(): for l in f.readlines(): l = l.strip() - if not l: continue - # Magic is here + if not l: + continue try: # Try to parse line as json yield json.loads(l) except ValueError: # If it is not json, we expect one word with '@' sign - assert len(l.split())==1 + assert len(l.split()) == 1 print l - login, domain = l.split('@') # ensure there is something email-like + login, domain = l.split('@') # ensure there is something email-like yield {'to': l} return wrapper() @@ -115,7 +118,7 @@ class MakeRFC822: def _generate_batch(self, batch, message): n = 0 for values in batch: - message.set_mail_to( values['to'] ) + message.set_mail_to(values['to']) message.render(**values.get('data', {})) s = message.as_string() n += 1 @@ -124,33 +127,23 @@ class MakeRFC822: def main(self): - options = self.options - message = self._get_message() - self._send_test_email(message) - if self.options.batch: batch = self._start_batch() self._generate_batch(batch, message) else: - batch = None - if self.options.output_format=='eml': + if self.options.output_format == 'eml': print(message.as_string()) - elif self.options.output_format=='html': + elif self.options.output_format == 'html': print(message.html_body) + self._send_test_email(message) - - - - - -if __name__=="__main__": - - - parser = argparse.ArgumentParser(description='Simple utility that imports html from url ang print generated rfc822 message to console.') +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description='Imports html from url ang generate rfc822 message.') parser.add_argument("-u", "--url", metavar="URL", dest="url", action="store", default=None, required=True) @@ -160,6 +153,8 @@ if __name__=="__main__": parser.add_argument("--message-id-domain", dest="message_id_domain", default=None, required=True) parser.add_argument("--add-header", dest="add_headers", action='append', default=None, required=False) + parser.add_argument("--add-header-imported-from", dest="add_header_imported_from", default=False, + action="store_true") parser.add_argument("--inline-images", action="store_true", dest="inline_images", default=False) @@ -174,12 +169,12 @@ if __name__=="__main__": parser.add_argument("--smtp-password", dest="smtp_password", default=None) parser.add_argument("--smtp-debug", dest="smtp_debug", action="store_true") - parser.add_argument("--batch", dest="batch", default=None) + parser.add_argument("--batch", dest="batch", default=None) parser.add_argument("--batch-start", dest="batch_start", default=None) parser.add_argument("--batch-limit", dest="batch_limit", default=None) options = parser.parse_args() - logging.basicConfig( level=logging.getLevelName(options.log_level.upper()) ) + logging.basicConfig(level=logging.getLevelName(options.log_level.upper())) MakeRFC822(options=options).main() diff --git a/setup.py b/setup.py index 6498ea5..994a477 100644 --- a/setup.py +++ b/setup.py @@ -56,26 +56,28 @@ class run_audit(Command): else: print("No problems found in sourcecode.") +import emails + settings.update( name='emails', - version='0.1.13', + version=emails.__version__, description='Elegant and simple email library for python 2/3', long_description=open('README.rst').read(), author='Sergey Lavrinenko', author_email='s@lavr.me', url='https://github.com/lavr/python-emails', - packages = ['emails', - 'emails.compat', - 'emails.loader', - 'emails.store', - 'emails.smtp', - 'emails.template', - 'emails.packages', - 'emails.packages.cssselect', - 'emails.packages.dkim' - ], - scripts=[ 'scripts/make_rfc822.py' ], - install_requires = [ 'cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests' ], + packages=['emails', + 'emails.compat', + 'emails.loader', + 'emails.store', + 'emails.smtp', + 'emails.template', + 'emails.packages', + 'emails.packages.cssselect', + 'emails.packages.dkim' + ], + scripts=['scripts/make_rfc822.py'], + install_requires=['cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests', 'premailer'], license=open('LICENSE').read(), #test_suite = "emails.testsuite.test_all", zip_safe=False, diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..b365588 --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +# Tox (http://tox.testrun.org/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +envlist = py26, py27, py33, py34 + +[testenv] +commands = py.test + +[testenv:py26] +deps = + -rrequirements/tests-2.6.txt + +[testenv:py27] +deps = + -rrequirements/tests-2.7.txt + +[testenv:py33] +deps = + -rrequirements/tests-3.3.txt + +[testenv:py34] +deps = + -rrequirements/tests-3.4.txt