python-emails refactored and redefined

This commit is contained in:
Sergey Lavrinenko 2015-02-21 23:56:59 +03:00
parent dd910dbb9a
commit c987129168
61 changed files with 1374 additions and 1795 deletions

8
.coveragerc Normal file
View File

@ -0,0 +1,8 @@
[run]
source = emails
[report]
omit =
emails/testsuite*
emails/packages*
emails/compat*

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
local_settings.py local_settings.py
local_*_settings.py
*.py[cod] *.py[cod]
# C extensions # C extensions

View File

@ -1,15 +1,20 @@
language: python language: python
sudo: no
python: python:
- "2.6" - "2.6"
- "2.7" - "2.7"
- "3.3" - "3.3"
- "3.4" - "3.4"
script: py.test script: py.test --cov emails
before_install:
- travis_retry pip install coverage coveralls pytest-cov
install: install:
- pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt --use-mirrors - travis_retry pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt
env: env:
- PIP_DOWNLOAD_CACHE=$HOME/.pip-cache - PIP_DOWNLOAD_CACHE=$HOME/.pip-cache
@ -17,3 +22,17 @@ env:
cache: cache:
directories: directories:
- $HOME/.pip-cache/ - $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"

10
Makefile Normal file
View File

@ -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

View File

@ -1,11 +1,23 @@
python-emails 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: Create message:
@ -21,54 +33,106 @@ Attach files or inline images:
:: ::
message.attach( data=open('event.ics'), filename='Event.ics' ) 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('image.png'), filename='image.png',
content_disposition='inline')
Add DKIM easily: Use templates:
::
message.dkim( key=open('my.key'), domain='mycompany.com', selector='newsletter' )
Templating:
:: ::
from emails.template import JinjaTemplate as T from emails.template import JinjaTemplate as T
message = emails.html(subject=T('Payment Receipt No.{{no}}'), message = emails.html(subject=T('Payment Receipt No.{{ billno }}'),
html=T('<p>Dear {{ name }}! This is a receipt for your subscription...'), html=T('<p>Dear {{ name }}! This is a receipt...'),
mail_from=('ABC', 'robot@mycompany.com')) 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 } message.dkim(key=open('my.key'), domain='mycompany.com', selector='newsletter')
r = message.send(to=('John Brown', 'jbrown@gmail.com'), smtp=SMTP)
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 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. Message HTML body can be modified with 'transformer' object:
Design email with less pain or even let designers make design:
:: ::
import emails >>> message = emails.Message(html="<img src='promo.png'>")
URL = 'http://_youproject_.github.io/newsletter/2013-08-14/index.html' >>> message.transformer.apply_to_images(func=lambda src, **kw: 'http://mycompany.tld/images/'+src)
page = emails.loader.from_url(URL, css_inline=True, make_links_absolute=True) >>> message.transformer.save()
message = emails.html(html=page.html, ...) >>> message.html
for mail_to in _get_maillist(): u'<html><body><img src="http://mycompany.tld/images/promo.png"></body></html>'
message.send(to=mail_to)
Code example to make images inline:
::
>>> message = emails.Message(html="<img src='promo.png'>")
>>> 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'<html><body><img src="cid:promo.png"></body></html>'
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 Install
------- -------
@ -88,30 +152,18 @@ Install on Ubuntu from PPA:
$ [sudo] apt-get install python-emails $ [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 TODO
---- ----
- Python3 (almost done)
- Add "safety stuff" from django (done)
- Django integration (django.core.mail.backends.smtp.EmailBackend subclass)
- Flask extension
- Documentation - Documentation
- 100% test coverage - 100% test coverage
- More accurate smtp session handling - 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, ... - ESP integration: Amazon SES, SendGrid, ...
- deb package (ubuntu package done) - deb package (ubuntu package almost done)
- rpm package - rpm package
- Patch pydkim for performance (i.e. preload key once, not each time)
- Flask extension
How to Help 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. 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 See also
-------- --------

View File

@ -23,10 +23,12 @@ More examples is at <https://github.com/lavr/python-emails/README.rst>.
""" """
__title__ = 'emails' __title__ = 'emails'
__version__ = '0.1.13' __version__ = '0.2'
__author__ = 'Sergey Lavrinenko' __author__ = 'Sergey Lavrinenko'
__license__ = 'Apache 2.0' __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 .message import Message, html
from .utils import MessageID from .utils import MessageID

4
emails/exc.py Normal file
View File

@ -0,0 +1,4 @@
# encoding: utf-8
class HTTPLoaderError(Exception):
pass

View File

@ -1,44 +1,99 @@
# encoding: utf-8 # encoding: utf-8
import os, os.path import os
import logging import os.path
from .htmlloader import HTTPLoader from emails.loader.helpers import guess_charset
from .fileloader import FileSystemLoader, ZipLoader from emails.compat import to_unicode
from .stylesheets import PageStylesheets 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): def from_url(url, message_params=None, requests_params=None, **kwargs):
loader = HTTPLoader()
loader.load_url(url=url, **kwargs) def _make_base_url(url):
return loader # /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 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): def from_file(filename, **kwargs):
return from_directory(directory=os.path.dirname(filename), index_file=os.path.basename(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() def from_zip(zip_file, message_params=None, **kwargs):
local_loader = FileSystemLoader(searchpath=directory) store = local_store.ZipLoader(file=zip_file)
index_file_name = local_loader.find_index_file(index_file) index_file_name = store.find_index_file()
dirname, basename = os.path.split(index_file_name) dirname, index_file_name = os.path.split(index_file_name)
if dirname: if dirname:
local_loader.base_path = dirname store.base_path = dirname
loader.load_file(local_loader[basename], local_loader=local_loader, **kwargs)
return loader
def from_zip(zip_file, **kwargs): message_params = message_params or {}
loader = HTTPLoader() message = Message(html=store[index_file_name], **message_params)
local_store = ZipLoader(file=zip_file) message.create_transformer(local_loader=store, requests_params=kwargs.get('requests_params'))
index_file_name = local_store.find_index_file() message.transformer.load_and_transform(**kwargs)
dirname, basename = os.path.split(index_file_name) message.transformer.save()
if dirname: return message
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
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

View File

@ -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 <style> right into body
return view
def transform(self, html):
if isinstance(html, string_types):
html = etree.HTML(html, parser=etree.HTMLParser())
view = self.getView(html, self.stylesheet)
# - add style into @style attribute
for element, style in list(view.items()):
v = style.getCssText(separator='')
element.set('style', v)
return html
transform_html = transform # compatibility

View File

@ -6,6 +6,7 @@ import re
import cgi import cgi
import chardet import chardet
from emails.compat import to_unicode from emails.compat import to_unicode
import logging
# HTML page charset stuff # HTML page charset stuff
@ -25,7 +26,6 @@ def guess_charset(headers, html):
# guess by http headers # guess by http headers
if headers: if headers:
#print(__name__, "guess_charset has headers", headers)
content_type = headers['content-type'] content_type = headers['content-type']
if content_type: if content_type:
_, params = cgi.parse_header(content_type) _, params = cgi.parse_header(content_type)
@ -34,7 +34,6 @@ def guess_charset(headers, html):
return r return r
# guess by html meta # guess by html meta
#print(__name__, "guess_charset html=", html[:1024])
for s in RE_META.findall(html): for s in RE_META.findall(html):
for x in RE_INSIDE_META.findall(s): for x in RE_INSIDE_META.findall(s):
for charset in RE_CHARSET.findall(x): for charset in RE_CHARSET.findall(x):
@ -44,50 +43,3 @@ def guess_charset(headers, html):
return chardet.detect(html)['encoding'] return chardet.detect(html)['encoding']
def set_content_type_meta(document, element_cls, content_type="text/html", charset="utf-8"):
if document is None:
document = element_cls('html')
if document.tag!='html':
html = element_cls('html')
html.insert(0, document)
document = html
else:
html = document
head = document.find('head')
if head is None:
head = element_cls('head')
html.insert(0, 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)
content_type_meta.set('content', '%s; charset=%s' % (content_type, charset))
content_type_meta.set('http-equiv', "Content-Type")
return document
def add_body_stylesheet(document, element_cls, cssText, tag="body"):
style = element_cls('style')
style.text = cssText
body = document.find(tag)
if body is None:
body = document
body.insert(0, style)
return style

View File

@ -1,392 +0,0 @@
# encoding: utf-8
from __future__ import unicode_literals
import posixpath
import os.path
import logging
from lxml import etree
import requests
from emails.compat import urlparse, to_unicode, to_bytes, text_type
from emails.store import MemoryFileStore, LazyHTTPFile
from .stylesheets import PageStylesheets, StyledTagWrapper
from .cssinliner import CSSInliner
from .helpers import guess_charset
from .wrappers import TAG_WRAPPER, CSS_WRAPPER
from . import helpers
class HTTPLoaderError(Exception):
pass
class HTTPLoader:
"""
HTML loader loads single html page and store it as some sort of web archive:
* loads html page
* loads linked images
* loads linked css and images from css
* converts css to inline html styles
"""
USER_AGENT = 'python-emails/1.0'
UNSAFE_TAGS = set(['script', 'object', 'iframe', 'frame', 'base', 'meta', 'link', 'style'])
TAGS_WITH_BACKGROUND = set(['td', 'tr', 'th', 'body'])
TAGS_WITH_IMAGES = TAGS_WITH_BACKGROUND.union(set(['img', ]))
CSS_MEDIA = ['', 'screen', 'all', 'email']
tag_link_cls = {
'a': TAG_WRAPPER('href'),
'link': TAG_WRAPPER('href'),
'img': TAG_WRAPPER('src'),
'td': TAG_WRAPPER('background'),
'table': TAG_WRAPPER('background'),
'th': TAG_WRAPPER('background'),
}
css_link_cls = CSS_WRAPPER
attached_image_cls = LazyHTTPFile
filestore_cls = MemoryFileStore
def __init__(self, filestore=None, encoding='utf-8', fetch_params=None):
self.filestore = filestore or self.filestore_cls()
self.encoding = encoding
self.fetch_params = fetch_params
self.stylesheets = PageStylesheets()
self.base_url = None
self._attachments = None
self.local_loader = None
def _fetch(self, url, valid_http_codes=(200, ), fetch_params=None):
_params = dict(allow_redirects=True, verify=False,
headers={'User-Agent': self.USER_AGENT})
fetch_params = fetch_params or self.fetch_params
if fetch_params:
_params.update(fetch_params)
response = requests.get(url, **_params)
if valid_http_codes and (response.status_code not in valid_http_codes):
raise HTTPLoaderError('Error loading url: %s. HTTP status: %s' % (url, response.http_status))
return response
def get_html_tree(self):
return self._html_tree
def set_html_tree(self, value):
self._html_tree = value
self._html = None # We never actually store html, only cached html_tree render
html_tree = property(get_html_tree, set_html_tree)
def tag_has_link(self, tag):
return tag in self.tag_link_cls
def start_load_url(self, url, base_url=None):
"""
Set some params and load start page
"""
# Load start page
response = self._fetch(url, valid_http_codes=(200, ), fetch_params=self.fetch_params)
self.start_url = url
self.base_url = base_url or url # Fixme: split base_url
self.headers = response.headers
content = response.content
self.html_encoding = guess_charset(response.headers, content)
if self.html_encoding:
content = to_unicode(content, self.html_encoding)
else:
content = to_unicode(content)
content = content.replace('\r\n', '\n') # Remove \r, or we'll get &#13;
self.html_content = content
def start_load_file(self, html, encoding="utf-8"):
"""
Set some params and load start page
"""
if hasattr(html, 'read'):
html = html.read()
if not isinstance(html, text_type):
html = to_unicode(html, encoding)
html = html.replace('\r\n', '\n') # Remove \r, or we'll get &#13;
self.html_content = html
self.html_encoding = encoding
self.start_url = None
self.base_url = None
self.headers = None
def start_load_string(self, html, css):
self.html_content = html
if css:
self.stylesheets.append(text=css)
self.html_encoding = 'utf-8'
self.start_url = None
self.base_url = None
self.headers = None
def make_html_tree(self):
self.html_tree = etree.HTML(self.html_content, parser=etree.HTMLParser())
# TODO: try another load methods, i.e. etree.fromstring(xml,
# base_url="http://where.it/is/from.xml") ?
def parse_html_tree(self, remove_unsafe_tags=True):
# Parse html, load important tags
self._a_links = []
self._tags_with_links = []
self._tags_with_images = []
for el in self.html_tree.iter():
if el.tag == 'img' or el.tag == 'a' or self.tag_has_link(el.tag):
self.process_tag_with_link(el)
if el.tag == 'base':
self.base_url = el.get('href') # TODO: can be relative link in BASE HREF ?
elif el.tag == 'link':
self.process_external_css_tag(el)
elif el.tag == 'style':
self.process_style_tag(el)
# elif el.tag=='a':
# self.process_a_tag( el )
if el.get('style'):
self.process_tag_with_style(el)
if remove_unsafe_tags and (el.tag in self.UNSAFE_TAGS):
# Remove unsafe tags
# self._removed_unsafe.append(el) # Save it for reports
p = el.getparent()
if p is not None:
p.remove(el)
# now make concatenated stylesheet
for prop in self.stylesheets.uri_properties:
self.process_stylesheet_uri_property(prop)
self.attach_all_images()
def load_url(self, url, base_url=None, **kwargs):
self.start_load_url(url=url, base_url=base_url)
return self._load(**kwargs)
def load_file(self, file, local_loader=None, **kwargs):
self.local_loader = local_loader
self.start_load_file(html=file)
return self._load(**kwargs)
def load_string(self, html, css, **kwargs):
self.start_load_string(html=html, css=css)
return self._load(**kwargs)
def _load(self,
css_inline=True,
remove_unsafe_tags=True,
make_links_absolute=False,
set_content_type_meta=True,
update_stylesheet=True,
images_inline=False):
self.make_html_tree()
self.parse_html_tree(remove_unsafe_tags=remove_unsafe_tags)
if make_links_absolute:
[self.make_link_absolute(obj) for obj in self.iter_image_links()]
[self.make_link_absolute(obj) for obj in self.iter_a_links()]
if remove_unsafe_tags and update_stylesheet:
self.stylesheets.attach_tag(self.insert_big_stylesheet())
# self.process_attaches()
# TODO: process images in self._tags_with_styles
if css_inline:
self.doinlinecss()
if set_content_type_meta:
self.set_content_type_meta()
if images_inline:
self.make_images_inline()
def process_external_css_tag(self, el):
"""
Process <link href="..." rel="stylesheet">
"""
if el.get('rel', '') == 'stylesheet' and el.get('media', '') in self.CSS_MEDIA:
url = el.get('href', '')
if url:
self.stylesheets.append(url=url,
absolute_url=self.absolute_url(url),
local_loader=self.local_loader)
def process_style_tag(self, el):
"""
Process: <style>...</style>
"""
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()

View File

@ -1,15 +1,15 @@
# encoding: utf-8 # encoding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import mimetypes
import os import os
from os import path from os import path
import errno import errno
from zipfile import ZipFile from zipfile import ZipFile
import email
from emails.compat import to_unicode, string_types from emails.compat import to_unicode, string_types
# FileSystemLoader adapted from jinja2.loaders
class FileNotFound(Exception): class FileNotFound(Exception):
pass pass
@ -74,6 +74,8 @@ class BaseLoader(object):
raise FileNotFound('index html') raise FileNotFound('index html')
# FileSystemLoader from jinja2.loaders
class FileSystemLoader(BaseLoader): class FileSystemLoader(BaseLoader):
"""Loads templates from the file system. This loader can find templates """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. 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): def get_source(self, name):
logging.debug('ZipLoader.get_source %s', name)
if self.base_path: if self.base_path:
name = path.join(self.base_path, name) name = path.join(self.base_path, name)
logging.debug('ZipLoader.get_source has base_path, result name is %s', name)
self._unpack_zip() self._unpack_zip()
@ -173,16 +172,117 @@ class ZipLoader(BaseLoader):
original_name = self._filenames.get(name) original_name = self._filenames.get(name)
logging.debug('ZipLoader.get_source original_name=%s', original_name)
if original_name is None: if original_name is None:
raise FileNotFound(name) raise FileNotFound(name)
data = self.zipfile.read(original_name) data = self.zipfile.read(original_name)
logging.debug('ZipLoader.get_source returns %s bytes', len(data))
return data, name return data, name
def list_files(self): def list_files(self):
self._unpack_zip() self._unpack_zip()
return sorted(self._filenames) 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

View File

@ -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

View File

@ -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

View File

@ -7,28 +7,19 @@ from functools import wraps
from dateutil.parser import parse as dateutil_parse from dateutil.parser import parse as dateutil_parse
from email.header import Header from email.header import Header
from email.utils import formatdate, getaddresses from email.utils import formatdate, getaddresses
from emails.compat import string_types, to_unicode, is_callable, to_bytes from emails.compat import string_types, to_unicode, is_callable, to_bytes
from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address,
from .utils import SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email parse_name_and_email, load_email_charsets,
encode_header as encode_header_)
from .smtp import ObjectFactory, SMTPBackend from .smtp import ObjectFactory, SMTPBackend
from .store import MemoryFileStore, BaseFile from .store import MemoryFileStore, BaseFile
from .signers import DKIMSigner from .signers import DKIMSigner
from .utils import load_email_charsets
load_email_charsets() # sic! load_email_charsets() # sic!
ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n'
class BadHeaderError(ValueError): class BadHeaderError(ValueError):
pass 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): def renderable(f):
@wraps(f) @wraps(f)
@ -48,23 +39,22 @@ class IncompleteMessage(Exception):
pass pass
class Message(object):
"""
Email class
message = HtmlEmail()
Message parts: class BaseMessage(object):
* html
* text
* attachments
""" """
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 attachment_cls = BaseFile
dkim_cls = DKIMSigner
smtp_pool_factory = ObjectFactory
smtp_cls = SMTPBackend
filestore_cls = MemoryFileStore filestore_cls = MemoryFileStore
def __init__(self, def __init__(self,
@ -87,20 +77,25 @@ class Message(object):
self.set_mail_from(mail_from) self.set_mail_from(mail_from)
self.set_mail_to(mail_to) self.set_mail_to(mail_to)
self.set_headers(headers) self.set_headers(headers)
self.set_html(html=html) # , url=self.html_from_url) self.set_html(html=html)
self.set_text(text=text) # , url=self.text_from_url) self.set_text(text=text)
self.render_data = {} self.render_data = {}
self._dkim_signer = None self.after_build = None
if attachments: if attachments:
for a in attachments: for a in attachments:
self.attachments.add(a) self.attachments.add(a)
self.after_build = None
def set_mail_from(self, mail_from): def set_mail_from(self, mail_from):
# In: ('Alice', '<alice@me.com>' ) # In: ('Alice', '<alice@me.com>' )
self._mail_from = mail_from and parse_name_and_email(mail_from) or None self._mail_from = mail_from and parse_name_and_email(mail_from) or None
def get_mail_from(self):
# Out: ('Alice', '<alice@me.com>') or None
return self._mail_from
mail_from = property(get_mail_from, set_mail_from)
def set_mail_to(self, mail_to): def set_mail_to(self, mail_to):
# Now we parse only one to-addr # Now we parse only one to-addr
# TODO: parse list of to-addrs # TODO: parse list of to-addrs
@ -121,25 +116,40 @@ class Message(object):
self._html = html self._html = html
self._html_url = url self._html_url = url
def get_html(self):
return self._html
html = property(get_html, set_html)
def set_text(self, text, url=None): def set_text(self, text, url=None):
if hasattr(text, 'read'): if hasattr(text, 'read'):
text = text.read() text = text.read()
self._text = text self._text = text
self._text_url = url self._text_url = url
def attach(self, **kwargs): def get_text(self):
if 'content_disposition' not in kwargs: return self._text
kwargs['content_disposition'] = 'attachment'
self.attachments.add(kwargs) text = property(get_text, set_text)
@classmethod @classmethod
def from_loader(cls, loader, template_cls=None, **kwargs): 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: html = loader.html
message.attach(**att.as_dict()) 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 return message
@property @property
@ -164,12 +174,6 @@ class Message(object):
def render(self, **kwargs): def render(self, **kwargs):
self.render_data = 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): def set_date(self, value):
if isinstance(value, string_types): if isinstance(value, string_types):
_d = dateutil_parse(value) _d = dateutil_parse(value)
@ -197,13 +201,7 @@ class Message(object):
return is_callable(mid) and mid() or mid return is_callable(mid) and mid() or mid
def encode_header(self, value): def encode_header(self, value):
value = to_unicode(value, charset=self.charset) return encode_header_(value, self.charset)
if isinstance(value, string_types):
value = value.rstrip()
_r = Header(value, self.charset)
return str(_r)
else:
return value
def encode_name_header(self, realname, email): def encode_name_header(self, realname, email):
if realname: if realname:
@ -222,18 +220,29 @@ class Message(object):
if '\n' in value or '\r' in value: if '\n' in value or '\r' in value:
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (value, key)) 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) value = ', '.join(sanitize_address(addr, self.charset)
for addr in getaddresses((value,))) for addr in getaddresses((value,)))
msg[key] = encode and self.encode_header(value) or 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): def _build_message(self, message_cls=None):
message_cls = message_cls or SafeMIMEMultipart message_cls = message_cls or SafeMIMEMultipart
msg = message_cls() 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, 'Date', self.message_date, encode=False)
self.set_header(msg, 'Message-ID', self.message_id(), 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 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) self.set_header(msg, 'To', mail_to, encode=False)
msgrel = SafeMIMEMultipart('related')
msg.attach(msgrel)
msgalt = SafeMIMEMultipart('alternative') msgalt = SafeMIMEMultipart('alternative')
msg.attach(msgalt) msgrel.attach(msgalt)
_text = self.text_body _text = self.text_body
_html = self.html_body _html = self.html_body
@ -275,34 +287,23 @@ class Message(object):
msgalt.attach(msghtml) msgalt.attach(msghtml)
for f in self.attachments: for f in self.attachments:
msgfile = f.mime part = f.mime
if msgfile: if part:
msg.attach(msgfile) if f.is_inline:
msgrel.attach(part)
else:
msg.attach(part)
if self.after_build: if self.after_build:
self.after_build(self, msg) self.after_build(self, msg)
return 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): class MessageSendMixin(object):
# self.as_string() is not equialent self.message().as_string()
# self.as_string() gets one less message-to-string conversions for dkim smtp_pool_factory = ObjectFactory
msg = self._build_message() smtp_cls = SMTPBackend
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
@property @property
def smtp_pool(self): def smtp_pool(self):
@ -311,9 +312,6 @@ class Message(object):
pool = self._smtp_pool = self.smtp_pool_factory(cls=self.smtp_cls) pool = self._smtp_pool = self.smtp_pool_factory(cls=self.smtp_cls)
return pool return pool
def dkim(self, **kwargs):
self._dkim_signer = self.dkim_cls(**kwargs)
def send(self, def send(self,
to=None, to=None,
set_mail_to=True, set_mail_to=True,
@ -361,7 +359,7 @@ class Message(object):
from_addr = self._mail_from[1] from_addr = self._mail_from[1]
if not from_addr: if not from_addr:
raise ValueError('No from-addr') raise ValueError('No "from" addr')
params = dict(from_addr=from_addr, params = dict(from_addr=from_addr,
to_addrs=[to_addr, ], to_addrs=[to_addr, ],
@ -376,6 +374,105 @@ class Message(object):
return response[0] 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): def html(**kwargs):
return Message(**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()

View File

@ -1,11 +1,10 @@
# encoding: utf-8 # encoding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
__all__ = [ 'SMTPSender' ] __all__ = ['SMTPBackend']
import smtplib import smtplib
import logging import logging
import threading
from functools import wraps from functools import wraps
from .client import SMTPResponse, SMTPClientWithResponse, SMTPClientWithResponse_SSL from .client import SMTPResponse, SMTPClientWithResponse, SMTPClientWithResponse_SSL
@ -31,25 +30,19 @@ class SMTPBackend:
def __init__(self, def __init__(self,
user=None,
password=None,
ssl=False, ssl=False,
tls=False,
debug=False,
fail_silently=True, fail_silently=True,
**kwargs): **kwargs):
self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls
self.debug = debug
self.ssl = ssl self.ssl = ssl
self.tls = tls self.tls = kwargs.get('tls')
if self.ssl and self.tls: if self.ssl and self.tls:
raise ValueError( raise ValueError(
"ssl/tls are mutually exclusive, so only set " "ssl/tls are mutually exclusive, so only set "
"one of those settings to True.") "one of those settings to True.")
self.user = user
self.password = password
if 'timeout' not in kwargs: if 'timeout' not in kwargs:
kwargs['timeout'] = self.DEFAULT_SOCKET_TIMEOUT kwargs['timeout'] = self.DEFAULT_SOCKET_TIMEOUT
self.smtp_cls_kwargs = kwargs self.smtp_cls_kwargs = kwargs
@ -59,10 +52,8 @@ class SMTPBackend:
self.fail_silently = fail_silently self.fail_silently = fail_silently
self.connection = None self.connection = None
#self.local_hostname=DNS_NAME.get_fqdn() #self.local_hostname=DNS_NAME.get_fqdn()
self._lock = threading.RLock()
def open(self): def open(self):
#logger.debug('SMTPSender _connect')
if self.connection is None: if self.connection is None:
self.connection = self.smtp_cls(parent=self, **self.smtp_cls_kwargs) self.connection = self.smtp_cls(parent=self, **self.smtp_cls_kwargs)
self.connection.initialize() self.connection.initialize()
@ -83,7 +74,6 @@ class SMTPBackend:
finally: finally:
self.connection = None self.connection = None
def make_response(self, exception=None): def make_response(self, exception=None):
return self.response_cls(host=self.host, port=self.port, exception=exception) 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=[]): 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)): if not isinstance(to_addrs, (list, tuple)):
to_addrs = [to_addrs, ] 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: try:
self.open() self.open()
except (IOError, smtplib.SMTPException) as e: except (IOError, smtplib.SMTPException) as e:
logger.exception("Error connecting smtp server") logger.exception("Error connecting smtp server")
response = self.make_response(exception = e) response = self.make_response(exception=e)
if not self.fail_silently: if not self.fail_silently:
response.raise_if_needed() response.raise_if_needed()
return [response, ] return [response, ]
@ -133,7 +119,6 @@ class SMTPBackend:
rcpt_options=rcpt_options) rcpt_options=rcpt_options)
if not self.fail_silently: if not self.fail_silently:
[ r.raise_if_needed() for r in response ] [r.raise_if_needed() for r in response]
return response return response

View File

@ -1,10 +1,8 @@
# encoding: utf-8 # encoding: utf-8
__all__ = ['SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL']
__all__ = [ 'SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL' ]
from smtplib import _have_ssl, SMTP from smtplib import _have_ssl, SMTP
import smtplib import smtplib
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -17,7 +15,6 @@ class SMTPResponse(object):
self.ssl = ssl self.ssl = ssl
self.responses = [] self.responses = []
self.exception = exception self.exception = exception
#self.complete = False
self.success = None self.success = None
self.from_addr = None self.from_addr = None
self.esmtp_opts = None self.esmtp_opts = None
@ -28,7 +25,7 @@ class SMTPResponse(object):
self.last_command = None self.last_command = None
def set_status(self, command, code, text): 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_code = code
self.status_text = text self.status_text = text
self.last_command = command self.last_command = command
@ -36,7 +33,7 @@ class SMTPResponse(object):
def set_exception(self, exc): def set_exception(self, exc):
self.exception = exc self.exception = exc
def raise_if_needed(): def raise_if_needed(self):
if self.exception: if self.exception:
raise self.exception raise self.exception
@ -49,9 +46,6 @@ class SMTPResponse(object):
self.status_text.__repr__()) self.status_text.__repr__())
#class SMTPCommandsLog:
class SMTPClientWithResponse(SMTP): class SMTPClientWithResponse(SMTP):
def __init__(self, parent, **kwargs): def __init__(self, parent, **kwargs):
@ -59,19 +53,17 @@ class SMTPClientWithResponse(SMTP):
self.make_response = parent.make_response self.make_response = parent.make_response
self._last_smtp_response = (None, None) self._last_smtp_response = (None, None)
self.tls = kwargs.pop('tls', False) 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.user = kwargs.pop('user', None)
self.password = kwargs.pop('password', None) self.password = kwargs.pop('password', None)
SMTP.__init__(self, **kwargs) SMTP.__init__(self, **kwargs)
self.initialize() self.initialize()
def initialize(self): def initialize(self):
if self.debug:
self.set_debuglevel(1)
if self.tls: if self.tls:
self.ehlo()
self.starttls() self.starttls()
self.ehlo()
if self.user: if self.user:
self.login(user=self.user, password=self.password) self.login(user=self.user, password=self.password)
self.ehlo_or_helo_if_needed() self.ehlo_or_helo_if_needed()
@ -92,7 +84,6 @@ class SMTPClientWithResponse(SMTP):
self._last_smtp_response = (code, msg) self._last_smtp_response = (code, msg)
return code, msg return code, msg
def _send_one_mail(self, from_addr, to_addr, msg, mail_options=[], rcpt_options=[]): def _send_one_mail(self, from_addr, to_addr, msg, mail_options=[], rcpt_options=[]):
esmtp_opts = [] esmtp_opts = []
@ -140,18 +131,17 @@ class SMTPClientWithResponse(SMTP):
return response return response
def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]): def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]):
# Send one email and returns one response # Send one email and returns one response
if not to_addrs: if not to_addrs:
raise StopIteration return []
assert isinstance(to_addrs, (list, tuple)) assert isinstance(to_addrs, (list, tuple))
if len(to_addrs)>1: if len(to_addrs)>1:
logger.warning('Beware: emails.smtp.client.SMTPClientWithResponse.sendmail sends full message to each email') 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) \ return [self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \
for to_addr in to_addrs ] for to_addr in to_addrs]
@ -160,18 +150,34 @@ if _have_ssl:
from smtplib import SMTP_SSL from smtplib import SMTP_SSL
import 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): def quit(self):
"""Closes the connection to the email server.""" """Closes the connection to the email server."""
try: try:
super(self, SMTPClientWithResponse_SSL).quit() SMTPClientWithResponse.quit(self)
except (ssl.SSLError, smtplib.SMTPServerDisconnected): except (ssl.SSLError, smtplib.SMTPServerDisconnected):
# This happens when calling quit() on a TLS connection # This happens when calling quit() on a TLS connection
# sometimes, or when the connection was already disconnected # sometimes, or when the connection was already disconnected
# by the server. # by the server.
self.close() self.close()
def sendmail(self, *args, **kw):
return SMTPClientWithResponse.sendmail(self, *args, **kw)
else: else:
class SMTPClientWithResponse_SSL: class SMTPClientWithResponse_SSL:

View File

@ -2,7 +2,7 @@
def simple_dict2str(d): def simple_dict2str(d):
# Simple dict serializer # 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 _serializer = simple_dict2str

View File

@ -10,8 +10,11 @@ import requests
from mimetypes import guess_type from mimetypes import guess_type
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from email.encoders import encode_base64 from email.encoders import encode_base64
import emails
from emails.compat import urlparse from emails.compat import urlparse
from emails.compat import string_types, to_bytes from emails.compat import string_types, to_bytes
from emails.utils import fetch_url, encode_header
# class FileNotFound(Exception): # class FileNotFound(Exception):
# pass # pass
@ -32,6 +35,8 @@ class BaseFile(object):
Store base "attachment-file" information. Store base "attachment-file" information.
""" """
content_id_suffix = '@python.emails'
def __init__(self, **kwargs): def __init__(self, **kwargs):
""" """
uri and filename are connected properties. 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.absolute_url = kwargs.get('absolute_url', None) or self.uri
self.filename = kwargs.get('filename', None) self.filename = kwargs.get('filename', None)
self.data = kwargs.get('data', None) self.data = kwargs.get('data', None)
self._mime_type = kwargs.get('mime_type', None) self._mime_type = kwargs.get('mime_type')
self._headers = kwargs.get('headers', None) self._headers = kwargs.get('headers')
self._content_disposition = kwargs.get('content_disposition', None) self._content_disposition = kwargs.get('content_disposition', 'attachment')
self.subtype = kwargs.get('subtype', None) self.subtype = kwargs.get('subtype')
self.local_loader = kwargs.get('local_loader', None) self.local_loader = kwargs.get('local_loader')
self.id = id
def as_dict(self, fields=None): def as_dict(self, fields=None):
fields = fields or ('uri', 'absolute_url', 'filename', 'data', fields = fields or ('uri', 'absolute_url', 'filename', 'data',
@ -119,21 +123,41 @@ class BaseFile(object):
content_disposition = property(get_content_disposition, set_content_disposition) 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 @property
def mime(self): def mime(self):
if self.content_disposition is None: if self.content_disposition is None:
return None return None
_mime = getattr(self, '_cached_mime', None) _mime = getattr(self, '_cached_mime', None)
if _mime is None: if _mime is None:
filename = str(Header(self.filename, 'utf-8')) filename_header = encode_header(self.filename)
self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1)) self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1), name=filename_header)
_mime.set_payload(to_bytes(self.data)) _mime.set_payload(to_bytes(self.data))
encode_base64(_mime) encode_base64(_mime)
_mime.add_header('Content-Disposition', _mime.add_header('Content-Disposition', self.content_disposition, filename=filename_header)
self.content_disposition,
filename=filename)
if self.content_disposition == 'inline': if self.content_disposition == 'inline':
_mime.add_header('Content-ID', '<{0}>'.format(filename)) _mime.add_header('Content-ID', '<%s>' % self.content_id)
return _mime return _mime
def reset_mime(self): def reset_mime(self):
@ -145,11 +169,9 @@ class BaseFile(object):
class LazyHTTPFile(BaseFile): class LazyHTTPFile(BaseFile):
def __init__(self, fetch_params=None, **kwargs): def __init__(self, requests_args=None, **kwargs):
BaseFile.__init__(self, **kwargs) BaseFile.__init__(self, **kwargs)
self.fetch_params = dict(allow_redirects=True, verify=False) self.requests_args = requests_args
if fetch_params:
self.fetch_params.update(fetch_params)
self._fetched = False self._fetched = False
def fetch(self): def fetch(self):
@ -162,7 +184,7 @@ class LazyHTTPFile(BaseFile):
self._data = data self._data = data
return 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: if r.status_code == 200:
self._data = r.content self._data = r.content
self._headers = r.headers self._headers = r.headers

View File

@ -18,7 +18,7 @@ class MemoryFileStore(FileStore):
if file_cls: if file_cls:
self.file_cls = file_cls self.file_cls = file_cls
self._files = OrderedDict() self._files = OrderedDict()
self._filenames = set() self._filenames = {}
def __contains__(self, k): def __contains__(self, k):
if isinstance(k, self.file_cls): if isinstance(k, self.file_cls):
@ -48,24 +48,26 @@ class MemoryFileStore(FileStore):
if v: if v:
filename = v.filename filename = v.filename
if filename and (filename in self._filenames): if filename and (filename in self._filenames):
self._filenames.remove(filename) del self._filenames[filename]
del self._files[uri] del self._files[uri]
def unique_filename(self, filename): def unique_filename(self, filename, uri=None):
if filename not in self._filenames: if filename in self._filenames:
return filename n = 1
basefilename, ext = splitext(filename)
n = 1 while True:
basefilename, ext = splitext(filename) n += 1
filename = "%s-%d%s" % (basefilename, n, ext)
if filename not in self._filenames:
break
else:
self._filenames[filename] = uri
while True: return filename
n += 1
filename = "%s-%d%s" % (basefilename, n, ext)
if filename not in self._filenames:
return filename
def add(self, value): def add(self, value, replace=False):
if isinstance(value, self.file_cls): if isinstance(value, self.file_cls):
uri = value.uri uri = value.uri
@ -75,24 +77,35 @@ class MemoryFileStore(FileStore):
else: else:
raise ValueError("Unknown file type: %s" % type(value)) raise ValueError("Unknown file type: %s" % type(value))
self.remove(uri) if (uri not in self._files) or replace:
value.filename = self.unique_filename(value.filename) self.remove(uri)
self._filenames.add(value.filename) value.filename = self.unique_filename(value.filename, uri=uri)
self._files[uri] = value 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) r = self._files.get(uri, None)
if r: if r:
return r return r
if synonims: if synonyms:
for _uri in synonims: for _uri in synonyms:
r = self._files.get(_uri, None) r = self._files.get(_uri, None)
if r: if r:
return r return r
return None 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): def __getitem__(self, uri):
return self._files.get(uri, None) return self.by_uri(uri) or self.by_filename(uri)
def __iter__(self): def __iter__(self):
for k in self._files: for k in self._files:

View File

@ -7,7 +7,7 @@ import logging
import threading import threading
import os import os
import os.path import os.path
import datetime
import pytest import pytest
@ -98,10 +98,82 @@ def smtp_server(request):
def django_email_backend(request): def django_email_backend(request):
from django.conf import settings from django.conf import settings
logger.debug('django_email_backend...') logger.debug('django_email_backend...')
server = smtp_server(request) settings.configure(EMAIL_BACKEND='django.core.mail.backends.filebased.EmailBackend',
settings.configure(EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', EMAIL_FILE_PATH='tmp-emails')
EMAIL_HOST=server.host, EMAIL_PORT=server.port)
from django.core.mail import get_connection from django.core.mail import get_connection
SETTINGS = {}
return get_connection() 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

View File

@ -1,6 +1,7 @@
# encoding: utf-8 # encoding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import emails import emails
import emails.message
def test_send_via_django_backend(django_email_backend): 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. Send email via django's email backend.
`django_email_backend` defined in conftest.py `django_email_backend` defined in conftest.py
""" """
message_params = {'html':'<p>Test from python-emails', message_params = {'html': '<p>Test from python-emails',
'mail_from': 's@lavr.me', 'mail_from': 's@lavr.me',
'mail_to': 's.lavrinenko@gmail.com', 'mail_to': 's.lavrinenko@gmail.com',
'subject': 'Test from python-emails'} 'subject': 'Test from python-emails'}
@ -22,3 +23,12 @@ def test_send_via_django_backend(django_email_backend):
headers = {'Reply-To': 'another@example.com'}) headers = {'Reply-To': 'another@example.com'})
backend.send_messages([email, ]) backend.send_messages([email, ])
def test_django_message_proxy(django_email_backend):
message_params = {'html': '<p>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), ])

View File

@ -1,152 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>SET-3-old-ornament</title>
</head>
<body style="background-color: #b7a98b; background-image: url(images/bg-all.jpg); color: #222121; font-family: 'Times New Roman', Times, serif; font-size: 13px; line-height: 16px; text-align: left;">
<table cellspacing="0" border="0" align="center" cellpadding="0" width="100%">
<tr>
<td valign="top">
<a name="top" style="text-decoration: none; color: #cc0000;"></a>
<table class="main-body" cellspacing="0" border="0" align="center" style="background-color: #d4c5a2; background-image: url(images/bg-main.jpg); font-family: 'Times New Roman', Times, serif;" cellpadding="0" width="616">
<tr>
<td class="unsubscribe" align="center" style="padding:20px 0"> <!-- unsubscribe -->
<p style="padding:0; margin: 0; font-family: 'Times New Roman', Times, serif; font-size: 12px;">You're receiving this newsletter because you bought widgets from us.<br />
Having trouble reading this email? <webversion style="color: #222121; text-decoration: underline;">View it in your browser</webversion>. Not interested anymore? <unsubscribe style="color: #222121; text-decoration: underline;">Unsubscribe</unsubscribe>.</p>
</td>
</tr>
<tr>
<td class="main-td" style="padding: 0 25px;"> <!-- introduction and menu box-->
<table class="intro" cellspacing="0" border="0" style="background-color: #e3ddca; background-image: url(images/bg-content.jpg); border-bottom: 1px solid #c3b697;" cellpadding="0" width="100%">
<tr>
<td valign="top" style="padding: 10px 12px 0px;" colspan="2">
<table class="banner" cellspacing="0" border="0" style="background: #550808; color: #fcfbfa;" cellpadding="0" width="100%">
<tr>
<td style="background: #e5ddca;"><img src="images/spacer.gif" height="2" style="display: block; border: none;" width="452" /></td>
<td align="right" style="background: #e5ddca;"><img src="images/banner-top.gif" height="2" style="display: block; border: none;" width="90" /></td>
</tr>
<tr>
<td class="title" valign="top" style="padding: 0 12px 0;">
<img src="images/spacer.gif" width="1" height="35" style="display: block; border: none;">
<h1 style="padding: 0; color:#fcfbfa; font-family: 'Times New Roman', Times, serif; font-size: 60px; line-height: 60px; margin: 0;">ABC Widgets</h1>
<p style="padding: 0; color:#fcfbfa; font-family: 'Times New Roman', Times, serif; font-size: 16px; text-transform: uppercase; margin: 0;"><currentmonthname> NEWSLETTER</p>
</td>
<td valign="top" align="right" width="90"><img src="images/banner-middle.gif" height="144" style="display: block; border: none;" width="90" /></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="content" align="left" valign="top" style="font-size: 15px; font-style: italic; line-height: 18px; padding:0 35px 12px 12px; width: 329px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0" style=" font-family: 'Times New Roman', Times, serif;">
<tr>
<td style="padding:25px 0 0;">
<p style="padding:0; font-family: 'Times New Roman', Times, serif;"><strong>Dear Simon,</strong></p>
<p style="padding:0; font-family: 'Times New Roman', Times, serif;">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.</p>
<p style="padding:0; font-family: 'Times New Roman', Times, serif;">Regards, ABC Widgets</p>
</td>
</tr>
</table>
</td>
<td class="menu" align="left" valign="top" style="width: 178px; padding: 0 12px 0 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td valign="top" align="right"><img src="images/banner-bottom.png" height="55" style="display: block; border: none;" width="178" /></td>
</tr>
<tr>
<td valign="top" align="left">
<ul style="margin: 0; padding: 0;">
<li style="font-size: 12px; font-family: 'Times New Roman', Times, serif; text-transform: uppercase; border-bottom: 1px solid #c0bcb1; color: #222121; list-style-type: none; padding: 5px 0; display:block">in this issue</li>
<li style=" font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article1" style="text-decoration: none; color: #cc0000;">Lorem ipsum dolor sit amet</a></li>
<li style=" font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article2" style="text-decoration: none; color: #cc0000;">Consectetuer adipiscing elit</a></li>
<li style=" font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article3" style="text-decoration: none; color: #cc0000;">Aliquam molestie quam vitae</a></li>
<li style=" font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article4" style="text-decoration: none; color: #cc0000;">Congue tristique</a></li>
</ul>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="footer" valign="top" colspan="2"><img src="images/spacer.gif" height="15" style="display: block; border: none;" width="1" /></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="flourish" valign="top" style="padding: 22px 25px;"><img src="images/flourish.png" height="35" style="display: block; border: none;" width="566" /></td>
</tr>
<tr>
<td align="left" valign="top" style="padding: 0 25px;">
<table cellspacing="0" cellpadding="0" border="0" width="100%" style=" font-family: 'Times New Roman', Times, serif; font-size:13px; color: #222121;">
<tr>
<!-- main content -->
<td align="left" valign="top" style="background-color:#e3dcc9; background-image: url(images/bg-content.jpg); padding: 13px;">
<p class="title" style="font-family: 'Times New Roman', Times, serif; padding: 8px 0; font-size: 18px; color: #ab1212; margin: 0;">Lorem Ipsum Dolor Sit Amet</p><a name="article1"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img01.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style="font-family: 'Times New Roman', Times, serif; padding: 0; margin:13px 0;">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</p>
<p class="title" style="font-family: 'Times New Roman', Times, serif; padding: 8px 0; font-size: 18px; color: #ab1212; margin: 0;">Fermentum Quam Etur Lectus</p><a name="article2"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img02.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style="font-family: 'Times New Roman', Times, serif; padding: 0; margin:13px 0;">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.</p>
<p class="title" style=" font-family: 'Times New Roman', Times, serif; padding: 8px 0; font-size: 18px; color: #ab1212; margin: 0;">Lorem Ipsum Dolor Sit Amet</p><a name="article3"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img03.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0; margin:13px 0;">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</p>
</td>
<!-- sidebar -->
<td align="left" valign="top" style="background-color:#e9e3d6; padding:13px 12px 13px 17px;">
<p style="padding:8px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Forward this issue</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0 0 13px; padding: 0;">Do you know someone who might be interested in receiving this monthly newsletter?</p>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;"><forwardtoafriend style="text-transform: uppercase; color: #cc0000; text-decoration: none;"><strong>forward</strong></forwardtoafriend></p>
<p style="padding:34px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Unsubscribe</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0 0 13px; padding: 0;">You're receiving this newsletter because you signed up for the ABC Widget Newsletter.</p>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;"><unsubscribe style="text-transform: uppercase; color: #cc0000; text-decoration: none;"><strong>unsubscribe</strong></unsubscribe></p>
<p style="padding:34px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Contact us</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0; padding: 0;">123 Some Street<br />
City, State<br />
99999<br />
(147) 789 7745<br />
<a href="#" style="text-decoration: none; color: #cc0000;">www.abcwidgets.com</a><br />
<a href="mailto:info@abcwidgets.com" style="text-decoration: none; color: #cc0000;">info@abcwidgets.com</a></p>
</td>
</tr>
<tr>
<!-- back to top -->
<td align="left" valign="top" style=" font-family: 'Times New Roman', Times, serif; background: #dfd8c8; padding: 10px 0 10px 14px;" colspan="2"><a href="#top" style="text-decoration: none; color: #cc0000;"><strong>Back to top</strong></a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding: 22px 25px;"><img src="images/flourish.png" height="35" style="display: block; border: none;" width="566" /></td>
</tr>
</table>
</td>
</tr>
</table>
</body>

View File

@ -1,153 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>SET-3-old-ornament</title>
</head>
<body style="background-color: #b7a98b; background-image: url(images/bg-all.jpg); color: #222121; font-family: 'Times New Roman', Times, serif; font-size: 13px; line-height: 16px; text-align: left;">
<table cellspacing="0" border="0" align="center" cellpadding="0" width="100%">
<tr>
<td valign="top">
<a name="top" style="text-decoration: none; color: #cc0000;"></a>
<table class="main-body" cellspacing="0" border="0" align="center" style="background-color: #d4c5a2; background-image: url(images/bg-main.jpg);" cellpadding="0" width="616">
<tr>
<td class="unsubscribe" align="center" style="padding:20px 0"> <!-- unsubscribe -->
<p style="padding:0; margin: 0; font-family: 'Times New Roman', Times, serif; font-size: 12px;">You're receiving this newsletter because you bought widgets from us.<br />
Having trouble reading this email? <webversion style="color: #222121; text-decoration: underline;">View it in your browser</webversion>. Not interested anymore? <unsubscribe style="color: #222121; text-decoration: underline;">Unsubscribe</unsubscribe>.</p>
</td>
</tr>
<tr>
<td class="main-td" style="padding: 0 25px;"> <!-- introduction and menu box-->
<table class="intro" cellspacing="0" border="0" style="background-color: #e3ddca; background-image: url(images/bg-content.jpg); border-bottom: 1px solid #c3b697;" cellpadding="0" width="100%">
<tr>
<td valign="top" style="padding: 10px 12px 0px;" colspan="2">
<table class="banner" cellspacing="0" border="0" style="background: #550808; color: #fcfbfa;" cellpadding="0" width="100%">
<tr>
<td style="background: #e5ddca;"><img src="images/spacer.gif" height="2" style="display: block; border: none;" width="452" /></td>
<td align="right" style="background: #e5ddca;"><img src="images/banner-top.gif" height="2" style="display: block; border: none;" width="90" /></td>
</tr>
<tr>
<td class="title" valign="top" style="padding: 0 12px 0;">
<img src="images/spacer.gif" width="1" height="35" style="display: block; border: none;">
<h1 style="padding: 0; color:#fcfbfa; font-family: 'Times New Roman', Times, serif; font-size: 60px; line-height: 60px; margin: 0;">ABC Widgets</h1>
<p style="padding: 0; color:#fcfbfa; font-family: 'Times New Roman', Times, serif; font-size: 16px; text-transform: uppercase; margin: 0;"><currentmonthname> NEWSLETTER</p>
</td>
<td valign="top" align="right" width="90"><img src="images/banner-middle.gif" height="144" style="display: block; border: none;" width="90" /></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="content" align="left" valign="top" style="font-size: 15px; font-style: italic; line-height: 18px; padding:0 35px 12px 12px; width: 329px;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td style="padding:25px 0 0;">
<p style=" font-family: 'Times New Roman', Times, serif; padding:0;"><strong>Dear Simon,</strong></p>
<p style=" font-family: 'Times New Roman', Times, serif; padding:0;">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.</p>
<p style="font-family: 'Times New Roman', Times, serif; padding:0;">Regards, ABC Widgets</p>
</td>
</tr>
</table>
</td>
<td class="menu" align="left" valign="top" style="width: 178px; padding: 0 12px 0 0;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td valign="top" align="right"><img src="images/banner-bottom.png" height="55" style="display: block; border: none;" width="178" /></td>
</tr>
<tr>
<td valign="top" align="left">
<ul style="margin: 0; padding: 0;">
<li style="font-family: 'Times New Roman', Times, serif; font-size: 12px; text-transform: uppercase; border-bottom: 1px solid #c0bcb1; color: #222121; list-style-type: none; padding: 5px 0; display:block">in this issue</li>
<li style="font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article1" style="text-decoration: none; color: #cc0000;">Lorem ipsum dolor sit amet</a></li>
<li style="font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article2" style="text-decoration: none; color: #cc0000;">Consectetuer adipiscing elit</a></li>
<li style="font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article3" style="text-decoration: none; color: #cc0000;">Aliquam molestie quam vitae</a></li>
<li style="font-family: 'Times New Roman', Times, serif; list-style-type: none; padding: 5px 0; border-bottom: 1px solid #c0bcb1; display:block"><a href="#article4" style="text-decoration: none; color: #cc0000;">Congue tristique</a></li>
</ul>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="footer" valign="top" colspan="2"><img src="images/spacer.gif" height="15" style="display: block; border: none;" width="1" /></td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="flourish" valign="top" style="padding: 22px 25px;"><img src="images/flourish.png" height="35" style="display: block; border: none;" width="566" /></td>
</tr>
<tr>
<td align="left" valign="top" style="padding: 0 25px;">
<table cellspacing="0" cellpadding="0" border="0" width="100%" >
<tr>
<!-- sidebar -->
<td align="left" valign="top" style="background-color:#e9e3d6; padding:13px 12px 13px 17px;">
<p style="padding:8px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Forward this issue</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0; padding: 0;">Do you know someone who might be interested in receiving this monthly newsletter?</p>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;"><forwardtoafriend style="text-transform: uppercase; color: #cc0000; text-decoration: none;"><strong>forward</strong></forwardtoafriend></p>
<p style="padding:34px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Unsubscribe</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0; padding: 0;">You're receiving this newsletter because you signed up for the ABC Widget Newsletter.</p>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;"><unsubscribe style="text-transform: uppercase; color: #cc0000; text-decoration: none;"><strong>unsubscribe</strong></unsubscribe></p>
<p style="padding:34px 0 8px; font-family: 'Times New Roman', Times, serif; font-size:18px; color:#222121; margin:0;">Contact us</p>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider2.jpg" height="5" style="display: block; border: none;" width="178" /></p>
<p style=" font-family: 'Times New Roman', Times, serif; margin: 0; padding: 0;">123 Some Street<br />
City, State<br />
99999<br />
(147) 789 7745<br />
<a href="#" style="text-decoration: none; color: #cc0000;">www.abcwidgets.com</a><br />
<a href="mailto:info@abcwidgets.com" style="text-decoration: none; color: #cc0000;">info@abcwidgets.com</a></p>
</td>
<!-- main content -->
<td align="left" valign="top" style="background-color:#e3dcc9; background-image: url(images/bg-content.jpg); padding: 13px;">
<p class="title" style="padding: 8px 0; font-family: 'Times New Roman', Times, serif; font-size: 18px; color: #ab1212; margin: 0;">Lorem Ipsum Dolor Sit Amet</p><a name="article1"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img01.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;">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</p>
<p class="title" style="padding: 8px 0; font-family: 'Times New Roman', Times, serif; font-size: 18px; color: #ab1212; margin: 0;">Fermentum Quam Etur Lectus</p><a name="article2"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img02.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;">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.</p>
<p class="title" style="padding: 8px 0; font-family: 'Times New Roman', Times, serif; font-size: 18px; color: #ab1212; margin: 0;">Lorem Ipsum Dolor Sit Amet</p><a name="article3"></a>
<p style="padding:0; margin:0 0 13px"><img class="divider" src="images/divider.jpg" height="5" style="display: block; border: none;" width="332" /></p>
<table cellspacing="0" border="0" style="background: #f0ece2; border: 1px solid #d5d2c9;" cellpadding="0" width="100%">
<tr>
<td style="padding: 11px;"><img src="images/img03.jpg" height="181" style="display: block; border: none;" width="311" /></td>
</tr>
</table>
<p style=" font-family: 'Times New Roman', Times, serif; padding: 0;">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</p>
</td>
</tr>
<tr>
<td style="background-color:#e2dbcd;">&nbsp;</td>
<!-- back to top -->
<td align="left" valign="top" style="background: #dfd8c8; padding: 10px 0 10px 14px;"><a href="#top" style="text-decoration: none; font-family: 'Times New Roman', Times, serif; color: #cc0000;"><strong>Back to top</strong></a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="left" valign="top" style="padding: 22px 25px;"><img src="images/flourish.png" height="35" style="display: block; border: none;" width="566" /></td>
</tr>
</table>
</td>
</tr>
</table>
</body>

View File

@ -156,7 +156,7 @@
City, State<br /> City, State<br />
99999<br /> 99999<br />
(147) 789 7745<br /> (147) 789 7745<br />
<a href="#" style="text-decoration: none; color: #cc0000;">www.abcwidgets.com</a><br /> <a href="" style="text-decoration: none; color: #cc0000;">www.abcwidgets.com</a><br />
<a href="mailto:info@abcwidgets.com" style="text-decoration: none; color: #cc0000;">info@abcwidgets.com</a></p> <a href="mailto:info@abcwidgets.com" style="text-decoration: none; color: #cc0000;">info@abcwidgets.com</a></p>
</td> </td>
</tr> </tr>

View File

@ -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 = """<div style="background: url('http://yandex.ru/bg.png'); color: black;"/>"""
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 = """ <img src="1.png" style="background: url(2.png)"> <style>p {background: url(3.png)} </style> """
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(
'<html><head><meta content="text/html; charset=utf-8" http-equiv="Content-Type"></head><body>'
'<style></style><img src="1.png" style="background: url(2.png)"> '
'<style>p {background: url(3.png)} </style> </body></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 = """ <img src="1.png" style="background: url(2.png)"> <style>p {background: url(3.png)} </style> """
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("""<html><head><meta content="text/html; charset=utf-8" http-equiv="Content-Type"/>"""
"""</head><body><style>p { background: url(prefix_3.png) }</style>"""
"""<img src="prefix_1.png" style="background: url(prefix_2.png)"/> </body></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 = "<a>b</a>"
CSS = "a:visited {color: red;}"
r = _do_inline_css(HTML, CSS) # , save_to_file='_result.html')
print(r)
def test_commons_css_inline():
tmpl = '''<html><head><title>style test</title></head><body>%s</body></html>'''
HTML = tmpl % '''
<h1>Style example 1</h1>
<p>&lt;p></p>
<p style="color: red;">&lt;p> with inline style: "color: red"</p>
<p id="x" style="color: red;">p#x with inline style: "color: red"</p>
<div>a &lt;div> green?</div>
<div id="y">#y pink?</div>
'''
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("""<html>
<head>
<title>style test</title>
</head>
<body style="margin: 0;color: blue !important;font: normal 100% sans-serif">
<h1 style="margin: 0">Style example 1</h1>
<p style="margin: 0;color: green;font-size: 2em">&lt;p&gt;</p>
<p style="color: red;margin: 0;font-size: 2em">&lt;p&gt; with inline style: "color: red"</p>
<p id="x" style="color: black !important;margin: 0;font-size: 2em">p#x with inline style: "color: red"</p>
<div style="margin: 0;color: green;font-size: 1.5em">a &lt;div&gt; green?</div>
<div id="y" style="margin: 0;color: #f0f;font-size: 1.5em">#y pink?</div>
</body>
</html>""")
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__())

View File

@ -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': '<h1>Привет!</h1><p>В первых строках...',
'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()

View File

@ -1,49 +1,31 @@
# coding: utf-8 # coding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import logging
import os import os
from emails.loader import cssinliner
import emails import emails
from emails.compat import StringIO from emails.compat import StringIO
from emails.template import JinjaTemplate 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') TRAVIS_CI = os.environ.get('TRAVIS')
HAS_INTERNET_CONNECTION = not TRAVIS_CI HAS_INTERNET_CONNECTION = not TRAVIS_CI
def common_email_data(**kwargs): def common_email_data(**kw):
data = {'charset': 'utf-8',
'subject': 'Что-то по-русски',
'mail_from': ('Максим Иванов', 'ivanov@ya.ru'),
'mail_to': ('Полина Сергеева', 'polina@mail.ru'),
'html': '<h1>Привет!</h1><p>В первых строках...',
'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):
T = JinjaTemplate T = JinjaTemplate
data = {'charset': 'utf-8', data = {'charset': 'utf-8',
'subject': T('Hello, {{name}}'), 'subject': T('[python-emails test] Olá {{name}}'),
'mail_from': ('Максим Иванов', 'sergei-nko@mail.ru'), 'mail_from': ('LÖVÅS HÅVET', FROM_EMAIL),
'mail_to': ('Полина Сергеева', 'sergei-nko@mail.ru'), 'mail_to': ('Pestävä erillään', TO_EMAIL),
'html': T('<h1>Привет, {{name}}!</h1><p>В первых строках...'), 'html': T('<h1>Olá {{name}}!</h1><p>O Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'),
'text': T('Привет, {{name}}!\nВ первых строках...'), 'text': T('Olá, {{name}}!\nO Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'),
'headers': {'X-Mailer': 'python-emails'}, 'headers': {'X-Mailer': 'python-emails'},
'message_id': emails.MessageID(),
'attachments': [ 'attachments': [
{'data': 'aaa', 'filename': 'Event.ics', 'content_disposition': 'attachment'}, {'data': 'aaa', 'filename': 'κατάσχεση.ics'},
{'data': 'bbb', 'filename': 'Карта.png', 'content_disposition': 'attachment'} {'data': 'bbb', 'filename': 'map.png'}
]} ]}
if kwargs: if kw:
data.update(kwargs) data.update(kw)
return data return data

View File

@ -1,13 +1,7 @@
# coding: utf-8 # coding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import logging
import os import os
from emails.loader import cssinliner
import emails import emails
from emails.compat import StringIO
from emails.template import JinjaTemplate
from emails.compat import NativeStringIO, to_bytes from emails.compat import NativeStringIO, to_bytes

View File

@ -1,12 +1,9 @@
# coding: utf-8 # coding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals, print_function
import emails import emails
from emails.compat import StringIO from emails.compat import to_unicode
from emails.template import JinjaTemplate from .helpers import common_email_data
from emails.compat import NativeStringIO, to_bytes
from .helpers import TRAVIS_CI, HAS_INTERNET_CONNECTION, _email_data, common_email_data
def test_message_build(): def test_message_build():
@ -18,7 +15,6 @@ def test_message_build():
def test_property_works(): def test_property_works():
m = emails.Message(subject='A') m = emails.Message(subject='A')
assert m._subject == 'A' assert m._subject == 'A'
m.subject = 'C' m.subject = 'C'
assert m._subject == 'C' assert m._subject == 'C'
@ -34,7 +30,9 @@ def test_after_build():
m = emails.Message(**kwargs) m = emails.Message(**kwargs)
m.after_build = my_after_build 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 # TODO: more tests here

View File

@ -1,79 +1,40 @@
# coding: utf-8 # coding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import logging import logging
import os
from emails.loader import cssinliner
import emails import emails
from emails.compat import StringIO import emails.loader
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 .helpers import HAS_INTERNET_CONNECTION, common_email_data
try: def test_send_attachment(smtp_servers):
from local_settings import SMTP_SERVER, SMTP_PORT, SMTP_SSL, SMTP_USER, SMTP_PASSWORD """
Test email with attachment
SMTP_DATA = {'host': SMTP_SERVER, 'port': SMTP_PORT, """
'ssl': SMTP_SSL, 'user': SMTP_USER, 'password': SMTP_PASSWORD, URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/images/gallery.png'
'debug': 0} data = common_email_data(subject='Single attachment', attachments=[emails.store.LazyHTTPFile(uri=URL), ])
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')
m = emails.html(**data) m = emails.html(**data)
if HAS_INTERNET_CONNECTION: 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(): def test_send_with_render(smtp_servers):
data = _email_data() data = common_email_data(subject='Render with name=John')
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')
m = emails.html(**data) m = emails.html(**data)
m.render(name='Полина')
if HAS_INTERNET_CONNECTION: if HAS_INTERNET_CONNECTION:
r = m.send(smtp=SMTP_DATA) for d in smtp_servers:
r = m.send(to='s.lavrinenko@gmail.com', smtp=SMTP_DATA) d.patch_message(m)
r = m.send(render={'name': u'John'}, smtp=d.params)
def test_send_inline_images(): def test_send_with_inline_images(smtp_servers):
data = _email_data() url = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html'
loader = emails.loader.HTTPLoader(filestore=emails.store.MemoryFileStore()) data = common_email_data(subject='Sample html with inline images')
URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html' del data['html']
loader.load_url(URL, css_inline=True, make_links_absolute=True, update_stylesheet=True) m = emails.loader.from_url(url=url, message_params=data, images_inline=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='Полина')
if HAS_INTERNET_CONNECTION: if HAS_INTERNET_CONNECTION:
r = m.send(smtp=SMTP_DATA) for d in smtp_servers:
if r.status_code != 250: d.patch_message(m)
logging.error("Error sending email, response=%s" % r) r = m.send(smtp=d.params)

View File

@ -1,13 +1,13 @@
# encoding: utf-8 # encoding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import emails import emails
import emails.store
def test_lazy_http(): def test_lazy_http():
IMG_URL = 'http://lavr.github.io/python-emails/tests/python-logo.gif' IMG_URL = 'http://lavr.github.io/python-emails/tests/python-logo.gif'
f = emails.store.LazyHTTPFile(uri=IMG_URL) f = emails.store.LazyHTTPFile(uri=IMG_URL)
assert f.filename == 'python-logo.gif' assert f.filename == 'python-logo.gif'
assert f.content_disposition is None assert f.content_disposition == 'attachment'
assert len(f.data) == 2549 assert len(f.data) == 2549
@ -20,3 +20,9 @@ def test_store_commons():
for (k, v) in orig_file.items(): for (k, v) in orig_file.items():
assert v == getattr(stored_file, k) 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'

View File

@ -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())

View File

@ -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.me>') == \
('John Smith', 'john@smith.me')
assert parse_name_and_email(['John Smith', 'john@smith.me']) == \
('John Smith', 'john@smith.me')

View File

@ -0,0 +1,42 @@
# encoding: utf-8
from __future__ import unicode_literals
from emails.transformer import Transformer
def test_image_apply():
pairs = [
("""<div style="background: url(3.png);"></div>""",
"""<div style="background: url(A/3.png)"></div>"""),
("""<img src="4.png">""",
"""<img src="A/4.png">"""),
("""<table background="5.png">""",
"""<table background="A/5.png">""")
]
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 = [
("""<a href="1"></a>""",
"""<a href="A/1"></a>"""),
]
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()

315
emails/transformer.py Normal file
View File

@ -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: '/*<![CDATA[*/%s/*]]>*/' % 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 <tag background="X">
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 <meta> 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()

View File

@ -1,5 +1,8 @@
# encoding: utf-8 # encoding: utf-8
from __future__ import unicode_literals from __future__ import unicode_literals
import emails
import requests
from emails.exc import HTTPLoaderError
__all__ = ['parse_name_and_email', 'load_email_charsets', 'MessageID'] __all__ = ['parse_name_and_email', 'load_email_charsets', 'MessageID']
@ -190,9 +193,27 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
def __setitem__(self, name, val): def __setitem__(self, name, val):
MIMEMultipart.__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') DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True,
assert parse_name_and_email('"John Smith" <john@smith.me>') == \ verify=False, timeout=10,
('John Smith', 'john@smith.me') headers={'User-Agent': emails.USER_AGENT})
assert parse_name_and_email(['John Smith', 'john@smith.me']) == \
('John Smith', 'john@smith.me')
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

View File

@ -3,3 +3,4 @@ lxml
chardet chardet
python-dateutil python-dateutil
requests requests
premailer

View File

@ -1,6 +1,6 @@
--requirement=base.txt --requirement=base.txt
--requirement=tests-base.txt
jinja2
mako
django==1.6 django==1.6
lamson lamson
ordereddict

View File

@ -1,6 +1,5 @@
--requirement=base.txt --requirement=base.txt
--requirement=tests-base.txt
jinja2
mako
django django
lamson lamson

View File

@ -1,5 +1,4 @@
--requirement=base.txt --requirement=base.txt
--requirement=tests-base.txt
jinja2 django
mako
django

View File

@ -1,5 +1,4 @@
--requirement=base.txt --requirement=base.txt
--requirement=tests-base.txt
jinja2 django
mako
django

View File

@ -0,0 +1,3 @@
jinja2
mako
pytest

View File

@ -7,13 +7,15 @@ Simple utility that imports html from url ang print generated rfc822 message to
Example usage: Example usage:
$ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html $ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html \
--subject="Some subject" --subject="Some subject" \
--from-name="Sergey Lavrinenko" --from-name="Sergey Lavrinenko" \
--from-email=s@lavr.me --from-email=s@lavr.me \
--message-id-domain=localhost --message-id-domain=localhost \
--send-test-email-to=sergei-nko@mail.ru --add-header="X-Test-Header: Test" \
--smtp-host=mxs.mail.ru --add-header-imported-from \
--send-test-email-to=sergei-nko@mail.ru \
--smtp-host=mxs.mail.ru \
--smtp-port=25 --smtp-port=25
Copyright 2013 Sergey Lavrinenko <s@lavr.me> Copyright 2013 Sergey Lavrinenko <s@lavr.me>
@ -32,7 +34,6 @@ from emails.template import JinjaTemplate as T
class MakeRFC822: class MakeRFC822:
def __init__(self, options): def __init__(self, options):
self.options = options self.options = options
@ -41,9 +42,14 @@ class MakeRFC822:
--add-header "X-Source: AAA" --add-header "X-Source: AAA"
""" """
r = {} r = {}
for s in self.options.add_headers: if self.options.add_headers:
(k, v) = s.split(':', 1) for s in self.options.add_headers:
r[k] = v (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 return r
def _get_message(self): def _get_message(self):
@ -51,23 +57,19 @@ class MakeRFC822:
options = self.options options = self.options
if options.message_id_domain: 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: else:
message_id = None message_id = None
loader = emails.loader.from_url(url=options.url, images_inline=options.inline_images) loader = emails.loader.from_url(url=options.url, images_inline=options.inline_images)
message = emails.Message.from_loader(loader=loader, message = emails.Message.from_loader(loader=loader,
headers= self._headers_from_command_line(), #{'X-Imported-From-URL': options.url }, headers=self._headers_from_command_line(),
template_cls=T, template_cls=T,
mail_from=(options.from_name, options.from_email), mail_from=(options.from_name, options.from_email),
subject=T(unicode(options.subject, 'utf-8')), subject=T(unicode(options.subject, 'utf-8')),
message_id=message_id message_id=message_id)
)
return message return message
def _send_test_email(self, message): def _send_test_email(self, message):
options = self.options options = self.options
@ -88,9 +90,10 @@ class MakeRFC822:
def _start_batch(self): def _start_batch(self):
fn = self.options.batch fn = self.options.batch
if not fn: return None if not fn:
return None
if fn=='-': if fn == '-':
f = sys.stdin f = sys.stdin
else: else:
f = open(fn, 'rb') f = open(fn, 'rb')
@ -98,16 +101,16 @@ class MakeRFC822:
def wrapper(): def wrapper():
for l in f.readlines(): for l in f.readlines():
l = l.strip() l = l.strip()
if not l: continue if not l:
# Magic is here continue
try: try:
# Try to parse line as json # Try to parse line as json
yield json.loads(l) yield json.loads(l)
except ValueError: except ValueError:
# If it is not json, we expect one word with '@' sign # If it is not json, we expect one word with '@' sign
assert len(l.split())==1 assert len(l.split()) == 1
print l 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} yield {'to': l}
return wrapper() return wrapper()
@ -115,7 +118,7 @@ class MakeRFC822:
def _generate_batch(self, batch, message): def _generate_batch(self, batch, message):
n = 0 n = 0
for values in batch: for values in batch:
message.set_mail_to( values['to'] ) message.set_mail_to(values['to'])
message.render(**values.get('data', {})) message.render(**values.get('data', {}))
s = message.as_string() s = message.as_string()
n += 1 n += 1
@ -124,33 +127,23 @@ class MakeRFC822:
def main(self): def main(self):
options = self.options
message = self._get_message() message = self._get_message()
self._send_test_email(message)
if self.options.batch: if self.options.batch:
batch = self._start_batch() batch = self._start_batch()
self._generate_batch(batch, message) self._generate_batch(batch, message)
else: else:
batch = None if self.options.output_format == 'eml':
if self.options.output_format=='eml':
print(message.as_string()) print(message.as_string())
elif self.options.output_format=='html': elif self.options.output_format == 'html':
print(message.html_body) print(message.html_body)
self._send_test_email(message)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description='Imports html from url ang generate rfc822 message.')
if __name__=="__main__":
parser = argparse.ArgumentParser(description='Simple utility that imports html from url ang print generated rfc822 message to console.')
parser.add_argument("-u", "--url", metavar="URL", dest="url", action="store", default=None, required=True) 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("--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", 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) 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-password", dest="smtp_password", default=None)
parser.add_argument("--smtp-debug", dest="smtp_debug", action="store_true") 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-start", dest="batch_start", default=None)
parser.add_argument("--batch-limit", dest="batch_limit", default=None) parser.add_argument("--batch-limit", dest="batch_limit", default=None)
options = parser.parse_args() 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() MakeRFC822(options=options).main()

View File

@ -56,26 +56,28 @@ class run_audit(Command):
else: else:
print("No problems found in sourcecode.") print("No problems found in sourcecode.")
import emails
settings.update( settings.update(
name='emails', name='emails',
version='0.1.13', version=emails.__version__,
description='Elegant and simple email library for python 2/3', description='Elegant and simple email library for python 2/3',
long_description=open('README.rst').read(), long_description=open('README.rst').read(),
author='Sergey Lavrinenko', author='Sergey Lavrinenko',
author_email='s@lavr.me', author_email='s@lavr.me',
url='https://github.com/lavr/python-emails', url='https://github.com/lavr/python-emails',
packages = ['emails', packages=['emails',
'emails.compat', 'emails.compat',
'emails.loader', 'emails.loader',
'emails.store', 'emails.store',
'emails.smtp', 'emails.smtp',
'emails.template', 'emails.template',
'emails.packages', 'emails.packages',
'emails.packages.cssselect', 'emails.packages.cssselect',
'emails.packages.dkim' 'emails.packages.dkim'
], ],
scripts=[ 'scripts/make_rfc822.py' ], scripts=['scripts/make_rfc822.py'],
install_requires = [ 'cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests' ], install_requires=['cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests', 'premailer'],
license=open('LICENSE').read(), license=open('LICENSE').read(),
#test_suite = "emails.testsuite.test_all", #test_suite = "emails.testsuite.test_all",
zip_safe=False, zip_safe=False,

26
tox.ini Normal file
View File

@ -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