python-emails refactored and redefined
|
@ -0,0 +1,8 @@
|
|||
[run]
|
||||
source = emails
|
||||
|
||||
[report]
|
||||
omit =
|
||||
emails/testsuite*
|
||||
emails/packages*
|
||||
emails/compat*
|
|
@ -1,4 +1,5 @@
|
|||
local_settings.py
|
||||
local_*_settings.py
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
|
|
23
.travis.yml
|
@ -1,15 +1,20 @@
|
|||
language: python
|
||||
|
||||
sudo: no
|
||||
|
||||
python:
|
||||
- "2.6"
|
||||
- "2.7"
|
||||
- "3.3"
|
||||
- "3.4"
|
||||
|
||||
script: py.test
|
||||
script: py.test --cov emails
|
||||
|
||||
before_install:
|
||||
- travis_retry pip install coverage coveralls pytest-cov
|
||||
|
||||
install:
|
||||
- pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt --use-mirrors
|
||||
- travis_retry pip install -r requirements/tests-$TRAVIS_PYTHON_VERSION.txt
|
||||
|
||||
env:
|
||||
- PIP_DOWNLOAD_CACHE=$HOME/.pip-cache
|
||||
|
@ -17,3 +22,17 @@ env:
|
|||
cache:
|
||||
directories:
|
||||
- $HOME/.pip-cache/
|
||||
|
||||
after_success:
|
||||
# Report coverage results to coveralls.io
|
||||
- coveralls
|
||||
|
||||
deploy:
|
||||
provider: pypi
|
||||
user: lavr
|
||||
password:
|
||||
secure: "WuFOsmKW77foHa0Ywv7pwXNvSQ+lHSx/IlYxPTuE7dTj1mNgvXC48NXQONY1ZEDiysryimgfsqumvx6PqLsFmOkG4r9k3gaau0eHE063+/hse0YvbqpnzIWa1FTe4yxreJeEHWSiNyAyo0ERaZVMcnj1ii6paHzuMVuCQ/BwV3k="
|
||||
on:
|
||||
branch: master
|
||||
tags: true
|
||||
distributions: "sdist bdist_wheel"
|
||||
|
|
|
@ -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
|
151
README.rst
|
@ -1,11 +1,23 @@
|
|||
python-emails
|
||||
=============
|
||||
|
||||
Emails without pain for python.
|
||||
Modern email handling in python.
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- HTML-email message abstraction
|
||||
- Method to transform html body:
|
||||
|
||||
- css inlining (using peterbe's premailer)
|
||||
- image inlining
|
||||
- DKIM signature
|
||||
- Message loaders
|
||||
- Send directly or via django email backend
|
||||
|
||||
|
||||
What can you do:
|
||||
----------------
|
||||
Examples:
|
||||
---------
|
||||
|
||||
Create message:
|
||||
|
||||
|
@ -21,54 +33,106 @@ Attach files or inline images:
|
|||
|
||||
::
|
||||
|
||||
message.attach( data=open('event.ics'), filename='Event.ics' )
|
||||
message.attach( data=open('image.png'), filename='image.png', content_disposition='inline' )
|
||||
message.attach(data=open('event.ics'), filename='Event.ics')
|
||||
message.attach(data=open('image.png'), filename='image.png',
|
||||
content_disposition='inline')
|
||||
|
||||
Add DKIM easily:
|
||||
|
||||
::
|
||||
|
||||
message.dkim( key=open('my.key'), domain='mycompany.com', selector='newsletter' )
|
||||
|
||||
|
||||
|
||||
Templating:
|
||||
Use templates:
|
||||
|
||||
::
|
||||
|
||||
from emails.template import JinjaTemplate as T
|
||||
|
||||
message = emails.html(subject=T('Payment Receipt No.{{no}}'),
|
||||
html=T('<p>Dear {{ name }}! This is a receipt for your subscription...'),
|
||||
message = emails.html(subject=T('Payment Receipt No.{{ billno }}'),
|
||||
html=T('<p>Dear {{ name }}! This is a receipt...'),
|
||||
mail_from=('ABC', 'robot@mycompany.com'))
|
||||
|
||||
message.send(to=('John Brown', 'jbrown@gmail.com'), render={'name': 'John Brown', 'billno':'141051906163'} )
|
||||
message.send(to=('John Brown', 'jbrown@gmail.com'),
|
||||
render={'name': 'John Brown', 'billno': '141051906163'})
|
||||
|
||||
Send without pain and (even) get response:
|
||||
|
||||
|
||||
Add DKIM signature:
|
||||
|
||||
::
|
||||
|
||||
SMTP = { 'host':'smtp.mycompany.com', 'port': 465, 'ssl': True }
|
||||
r = message.send(to=('John Brown', 'jbrown@gmail.com'), smtp=SMTP)
|
||||
message.dkim(key=open('my.key'), domain='mycompany.com', selector='newsletter')
|
||||
|
||||
Generate email.message or rfc822 string:
|
||||
|
||||
::
|
||||
|
||||
m = message.as_message()
|
||||
s = message.as_string()
|
||||
|
||||
|
||||
|
||||
Send and get response from smtp server:
|
||||
|
||||
::
|
||||
|
||||
r = message.send(to=('John Brown', 'jbrown@gmail.com'),
|
||||
smtp={'host':'smtp.mycompany.com', 'port': 465, 'ssl': True})
|
||||
assert r.status_code == 250
|
||||
|
||||
Or send via Django email backend:
|
||||
|
||||
::
|
||||
from django.core.mail import get_connection
|
||||
from emails.message import DjangoMessageProxy
|
||||
c = django.core.mail.get_connection()
|
||||
c.send_messages([DjangoMessageProxy(message), ])
|
||||
|
||||
|
||||
HTML transformer
|
||||
----------------
|
||||
|
||||
One more thing
|
||||
--------------
|
||||
|
||||
Library ships with fairy email-from-html loader.
|
||||
Design email with less pain or even let designers make design:
|
||||
Message HTML body can be modified with 'transformer' object:
|
||||
|
||||
::
|
||||
|
||||
import emails
|
||||
URL = 'http://_youproject_.github.io/newsletter/2013-08-14/index.html'
|
||||
page = emails.loader.from_url(URL, css_inline=True, make_links_absolute=True)
|
||||
message = emails.html(html=page.html, ...)
|
||||
for mail_to in _get_maillist():
|
||||
message.send(to=mail_to)
|
||||
>>> message = emails.Message(html="<img src='promo.png'>")
|
||||
>>> message.transformer.apply_to_images(func=lambda src, **kw: 'http://mycompany.tld/images/'+src)
|
||||
>>> message.transformer.save()
|
||||
>>> message.html
|
||||
u'<html><body><img src="http://mycompany.tld/images/promo.png"></body></html>'
|
||||
|
||||
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
|
||||
-------
|
||||
|
@ -88,30 +152,18 @@ Install on Ubuntu from PPA:
|
|||
$ [sudo] apt-get install python-emails
|
||||
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- Internationalization & Unicode bodies
|
||||
- DKIM signatures
|
||||
- HTML page loader & CSS inliner
|
||||
- Body and attachments http import
|
||||
- Body & headers preprocessors
|
||||
|
||||
TODO
|
||||
----
|
||||
- Python3 (almost done)
|
||||
- Add "safety stuff" from django (done)
|
||||
- Django integration (django.core.mail.backends.smtp.EmailBackend subclass)
|
||||
- Flask extension
|
||||
|
||||
- Documentation
|
||||
- 100% test coverage
|
||||
- More accurate smtp session handling
|
||||
- Some patches for pydkim performance (i.e. preload key once, not each time)
|
||||
- More genius css inliner
|
||||
- Catch all bugs
|
||||
- ESP integration: Amazon SES, SendGrid, ...
|
||||
- deb package (ubuntu package done)
|
||||
- deb package (ubuntu package almost done)
|
||||
- rpm package
|
||||
- Patch pydkim for performance (i.e. preload key once, not each time)
|
||||
- Flask extension
|
||||
|
||||
How to Help
|
||||
-----------
|
||||
|
@ -124,13 +176,6 @@ Library is under development and contributions are welcome!
|
|||
4. Send a pull request. Make sure to add yourself to AUTHORS.
|
||||
|
||||
|
||||
Background
|
||||
----------
|
||||
|
||||
API structure inspired by python-requests and werkzeug libraries.
|
||||
Some code is from my mailcube.ru experience.
|
||||
|
||||
|
||||
See also
|
||||
--------
|
||||
|
||||
|
|
|
@ -23,10 +23,12 @@ More examples is at <https://github.com/lavr/python-emails/README.rst>.
|
|||
"""
|
||||
|
||||
__title__ = 'emails'
|
||||
__version__ = '0.1.13'
|
||||
__version__ = '0.2'
|
||||
__author__ = 'Sergey Lavrinenko'
|
||||
__license__ = 'Apache 2.0'
|
||||
__copyright__ = 'Copyright 2013 Sergey Lavrinenko'
|
||||
__copyright__ = 'Copyright 2013-2015 Sergey Lavrinenko'
|
||||
|
||||
USER_AGENT = 'python-emails/%s' % __version__
|
||||
|
||||
from .message import Message, html
|
||||
from .utils import MessageID
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
# encoding: utf-8
|
||||
|
||||
class HTTPLoaderError(Exception):
|
||||
pass
|
|
@ -1,44 +1,99 @@
|
|||
# encoding: utf-8
|
||||
import os, os.path
|
||||
import logging
|
||||
from .htmlloader import HTTPLoader
|
||||
from .fileloader import FileSystemLoader, ZipLoader
|
||||
from .stylesheets import PageStylesheets
|
||||
import os
|
||||
import os.path
|
||||
from emails.loader.helpers import guess_charset
|
||||
from emails.compat import to_unicode
|
||||
from emails.compat import urlparse
|
||||
from emails import Message
|
||||
from emails.utils import fetch_url
|
||||
from emails.loader import local_store
|
||||
|
||||
|
||||
def from_url(url, **kwargs):
|
||||
loader = HTTPLoader()
|
||||
loader.load_url(url=url, **kwargs)
|
||||
return loader
|
||||
def from_url(url, message_params=None, requests_params=None, **kwargs):
|
||||
|
||||
def _make_base_url(url):
|
||||
# /a/b.html -> /a
|
||||
p = list(urlparse.urlparse(url))[:5]
|
||||
p[2] = os.path.split(p[2])[0]
|
||||
return urlparse.urlunsplit(p)
|
||||
|
||||
# Load html page
|
||||
r = fetch_url(url, requests_args=requests_params)
|
||||
html = r.content
|
||||
html = to_unicode(html, charset=guess_charset(r.headers, html))
|
||||
html = html.replace('\r\n', '\n') # Remove \r
|
||||
|
||||
message_params = message_params or {}
|
||||
message = Message(html=html, **message_params)
|
||||
message.create_transformer(requests_params=requests_params,
|
||||
base_url=_make_base_url(url))
|
||||
message.transformer.load_and_transform(**kwargs)
|
||||
message.transformer.save()
|
||||
return message
|
||||
|
||||
load_url = from_url
|
||||
|
||||
|
||||
def from_directory(directory, index_file=None, message_params=None, **kwargs):
|
||||
|
||||
store = local_store.FileSystemLoader(searchpath=directory)
|
||||
index_file_name = store.find_index_file(index_file)
|
||||
dirname, _ = os.path.split(index_file_name)
|
||||
if dirname:
|
||||
store.base_path = dirname
|
||||
|
||||
message_params = message_params or {}
|
||||
message = Message(html=store[index_file_name], **message_params)
|
||||
message.create_transformer(local_loader=store, requests_params=kwargs.get('requests_params'))
|
||||
message.transformer.load_and_transform(**kwargs)
|
||||
message.transformer.save()
|
||||
return message
|
||||
|
||||
|
||||
def from_file(filename, **kwargs):
|
||||
return from_directory(directory=os.path.dirname(filename), index_file=os.path.basename(filename), **kwargs)
|
||||
|
||||
def from_directory(directory, index_file=None, **kwargs):
|
||||
loader = HTTPLoader()
|
||||
local_loader = FileSystemLoader(searchpath=directory)
|
||||
index_file_name = local_loader.find_index_file(index_file)
|
||||
dirname, basename = os.path.split(index_file_name)
|
||||
|
||||
def from_zip(zip_file, message_params=None, **kwargs):
|
||||
store = local_store.ZipLoader(file=zip_file)
|
||||
index_file_name = store.find_index_file()
|
||||
dirname, index_file_name = os.path.split(index_file_name)
|
||||
if dirname:
|
||||
local_loader.base_path = dirname
|
||||
loader.load_file(local_loader[basename], local_loader=local_loader, **kwargs)
|
||||
return loader
|
||||
store.base_path = dirname
|
||||
|
||||
def from_zip(zip_file, **kwargs):
|
||||
loader = HTTPLoader()
|
||||
local_store = ZipLoader(file=zip_file)
|
||||
index_file_name = local_store.find_index_file()
|
||||
dirname, basename = os.path.split(index_file_name)
|
||||
if dirname:
|
||||
local_store.base_path = dirname
|
||||
logging.debug('from_zip: found index file: %s', index_file_name)
|
||||
loader.load_file(local_store[basename], local_loader=local_store, **kwargs)
|
||||
return loader
|
||||
message_params = message_params or {}
|
||||
message = Message(html=store[index_file_name], **message_params)
|
||||
message.create_transformer(local_loader=store, requests_params=kwargs.get('requests_params'))
|
||||
message.transformer.load_and_transform(**kwargs)
|
||||
message.transformer.save()
|
||||
return message
|
||||
|
||||
def from_string(html, css=None, **kwargs):
|
||||
loader = HTTPLoader()
|
||||
loader.load_string(html=html, css=css, **kwargs)
|
||||
return loader
|
||||
|
||||
def from_html(html, base_url=None, message_params=None, **kwargs):
|
||||
message_params = message_params or {}
|
||||
message = Message(html=html, **message_params)
|
||||
message.create_transformer(requests_params=kwargs.get('requests_params'), base_url=base_url)
|
||||
message.transformer.load_and_transform(**kwargs)
|
||||
message.transformer.save()
|
||||
return message
|
||||
|
||||
from_string = from_html
|
||||
|
||||
|
||||
def from_rfc822(msg, message_params=None, **kw):
|
||||
|
||||
store = local_store.MsgLoader(msg=msg)
|
||||
text = store.get_source('__index.txt')
|
||||
html = store.get_source('__index.html')
|
||||
|
||||
message_params = message_params or {}
|
||||
message = Message(html=html, text=text, **message_params)
|
||||
if html:
|
||||
message.create_transformer(local_loader=store, **kw)
|
||||
message.transformer.load_and_transform()
|
||||
message.transformer.save()
|
||||
else:
|
||||
# TODO: add attachments for text-only message
|
||||
pass
|
||||
|
||||
return message
|
|
@ -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
|
|
@ -6,6 +6,7 @@ import re
|
|||
import cgi
|
||||
import chardet
|
||||
from emails.compat import to_unicode
|
||||
import logging
|
||||
|
||||
# HTML page charset stuff
|
||||
|
||||
|
@ -25,7 +26,6 @@ def guess_charset(headers, html):
|
|||
|
||||
# guess by http headers
|
||||
if headers:
|
||||
#print(__name__, "guess_charset has headers", headers)
|
||||
content_type = headers['content-type']
|
||||
if content_type:
|
||||
_, params = cgi.parse_header(content_type)
|
||||
|
@ -34,7 +34,6 @@ def guess_charset(headers, html):
|
|||
return r
|
||||
|
||||
# guess by html meta
|
||||
#print(__name__, "guess_charset html=", html[:1024])
|
||||
for s in RE_META.findall(html):
|
||||
for x in RE_INSIDE_META.findall(s):
|
||||
for charset in RE_CHARSET.findall(x):
|
||||
|
@ -44,50 +43,3 @@ def guess_charset(headers, html):
|
|||
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
|
||||
|
|
|
@ -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
|
||||
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
|
||||
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()
|
||||
|
|
@ -1,15 +1,15 @@
|
|||
# encoding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from os import path
|
||||
import errno
|
||||
from zipfile import ZipFile
|
||||
import email
|
||||
|
||||
from emails.compat import to_unicode, string_types
|
||||
|
||||
# FileSystemLoader adapted from jinja2.loaders
|
||||
|
||||
|
||||
class FileNotFound(Exception):
|
||||
pass
|
||||
|
@ -74,6 +74,8 @@ class BaseLoader(object):
|
|||
raise FileNotFound('index html')
|
||||
|
||||
|
||||
# FileSystemLoader from jinja2.loaders
|
||||
|
||||
class FileSystemLoader(BaseLoader):
|
||||
"""Loads templates from the file system. This loader can find templates
|
||||
in folders on the file system and is the preferred way to load them.
|
||||
|
@ -155,11 +157,8 @@ class ZipLoader(BaseLoader):
|
|||
|
||||
def get_source(self, name):
|
||||
|
||||
logging.debug('ZipLoader.get_source %s', name)
|
||||
|
||||
if self.base_path:
|
||||
name = path.join(self.base_path, name)
|
||||
logging.debug('ZipLoader.get_source has base_path, result name is %s', name)
|
||||
|
||||
self._unpack_zip()
|
||||
|
||||
|
@ -173,16 +172,117 @@ class ZipLoader(BaseLoader):
|
|||
|
||||
original_name = self._filenames.get(name)
|
||||
|
||||
logging.debug('ZipLoader.get_source original_name=%s', original_name)
|
||||
|
||||
if original_name is None:
|
||||
raise FileNotFound(name)
|
||||
|
||||
data = self.zipfile.read(original_name)
|
||||
|
||||
logging.debug('ZipLoader.get_source returns %s bytes', len(data))
|
||||
return data, name
|
||||
|
||||
def list_files(self):
|
||||
self._unpack_zip()
|
||||
return sorted(self._filenames)
|
||||
|
||||
|
||||
class MsgLoader(BaseLoader):
|
||||
"""
|
||||
Load files from email.Message
|
||||
Thanks to
|
||||
http://blog.magiksys.net/parsing-email-using-python-content
|
||||
"""
|
||||
|
||||
common_charsets = ['ascii', 'utf-8', 'utf-16', 'windows-1252', 'cp850', 'windows-1251']
|
||||
|
||||
def __init__(self, msg, base_path=None):
|
||||
if isinstance(msg, string_types):
|
||||
self.msg = email.message_from_string(msg)
|
||||
else:
|
||||
self.msg = msg
|
||||
self.base_path = base_path
|
||||
self._html_files = []
|
||||
self._text_files = []
|
||||
self._files = {}
|
||||
|
||||
|
||||
def decode_text(self, text, charset=None):
|
||||
if charset:
|
||||
try:
|
||||
return text.decode(charset), charset
|
||||
except UnicodeError:
|
||||
pass
|
||||
for charset in self.common_charsets:
|
||||
try:
|
||||
return text.decode(charset), charset
|
||||
except UnicodeError:
|
||||
pass
|
||||
return text, None
|
||||
|
||||
def clean_content_id(self, content_id):
|
||||
if content_id.startswith('<'):
|
||||
content_id = content_id[1:]
|
||||
if content_id.endswith('>'):
|
||||
content_id = content_id[:-1]
|
||||
return content_id
|
||||
|
||||
def extract_part_text(self, part):
|
||||
return self.decode_text(part.get_payload(decode=True), charset=part.get_param('charset'))[0]
|
||||
|
||||
def add_html_part(self, part):
|
||||
name = '__index.html'
|
||||
self._files[name] = {'data': self.extract_part_text(part),
|
||||
'filename': name,
|
||||
'content_type': part.get_content_type()}
|
||||
|
||||
def add_text_part(self, part):
|
||||
name = '__index.txt'
|
||||
self._files[name] = {'data': self.extract_part_text(part),
|
||||
'filename': name,
|
||||
'content_type': part.get_content_type()}
|
||||
|
||||
def add_another_part(self, part):
|
||||
counter = 1
|
||||
f = {}
|
||||
content_id = part['Content-ID']
|
||||
if content_id:
|
||||
f['filename'] = self.clean_content_id(content_id)
|
||||
f['inline'] = True
|
||||
else:
|
||||
filename = part.get_filename()
|
||||
if not filename:
|
||||
ext = mimetypes.guess_extension(part.get_content_type())
|
||||
if not ext:
|
||||
# Use a generic bag-of-bits extension
|
||||
ext = '.bin'
|
||||
filename = 'part-%03d%s' % (counter, ext)
|
||||
counter += 1
|
||||
f['filename'] = filename
|
||||
f['content_type'] = part.get_content_type()
|
||||
f['data'] = part.get_payload(decode=True)
|
||||
self._files[f['filename']] = f
|
||||
|
||||
def _parse_msg(self):
|
||||
for part in self.msg.walk():
|
||||
content_type = part.get_content_type()
|
||||
|
||||
if content_type.startswith('multipart/'):
|
||||
continue
|
||||
|
||||
if content_type == 'text/html':
|
||||
self.add_html_part(part)
|
||||
continue
|
||||
|
||||
if content_type == 'text/plain':
|
||||
self.add_text_part(part)
|
||||
continue
|
||||
|
||||
self.add_another_part(part)
|
||||
|
||||
def get_source(self, name):
|
||||
self._parse_msg()
|
||||
f = self._files.get(name)
|
||||
if f:
|
||||
return f['data'], name
|
||||
return None, name
|
||||
|
||||
def list_files(self):
|
||||
return self._files
|
|
@ -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
|
|
@ -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
|
|
@ -7,28 +7,19 @@ from functools import wraps
|
|||
from dateutil.parser import parse as dateutil_parse
|
||||
from email.header import Header
|
||||
from email.utils import formatdate, getaddresses
|
||||
|
||||
from emails.compat import string_types, to_unicode, is_callable, to_bytes
|
||||
|
||||
from .utils import SafeMIMEText, SafeMIMEMultipart, sanitize_address, parse_name_and_email
|
||||
from .utils import (SafeMIMEText, SafeMIMEMultipart, sanitize_address,
|
||||
parse_name_and_email, load_email_charsets,
|
||||
encode_header as encode_header_)
|
||||
from .smtp import ObjectFactory, SMTPBackend
|
||||
from .store import MemoryFileStore, BaseFile
|
||||
from .signers import DKIMSigner
|
||||
|
||||
from .utils import load_email_charsets
|
||||
|
||||
load_email_charsets() # sic!
|
||||
|
||||
ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n'
|
||||
|
||||
|
||||
class BadHeaderError(ValueError):
|
||||
pass
|
||||
|
||||
# Header names that contain structured address data (RFC #5322)
|
||||
ADDRESS_HEADERS = set(['from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'resent-from', 'resent-sender', 'resent-to',
|
||||
'resent-cc', 'resent-bcc'])
|
||||
|
||||
|
||||
def renderable(f):
|
||||
@wraps(f)
|
||||
|
@ -48,23 +39,22 @@ class IncompleteMessage(Exception):
|
|||
pass
|
||||
|
||||
|
||||
class Message(object):
|
||||
"""
|
||||
Email class
|
||||
|
||||
message = HtmlEmail()
|
||||
|
||||
Message parts:
|
||||
* html
|
||||
* text
|
||||
* attachments
|
||||
class BaseMessage(object):
|
||||
|
||||
"""
|
||||
Base email message with html part, text part and attachments.
|
||||
"""
|
||||
|
||||
ROOT_PREAMBLE = 'This is a multi-part message in MIME format.\n'
|
||||
|
||||
# Header names that contain structured address data (RFC #5322)
|
||||
ADDRESS_HEADERS = set(['from', 'sender', 'reply-to', 'to', 'cc', 'bcc',
|
||||
'resent-from', 'resent-sender', 'resent-to',
|
||||
'resent-cc', 'resent-bcc'])
|
||||
|
||||
attachment_cls = BaseFile
|
||||
dkim_cls = DKIMSigner
|
||||
smtp_pool_factory = ObjectFactory
|
||||
smtp_cls = SMTPBackend
|
||||
filestore_cls = MemoryFileStore
|
||||
|
||||
def __init__(self,
|
||||
|
@ -87,20 +77,25 @@ class Message(object):
|
|||
self.set_mail_from(mail_from)
|
||||
self.set_mail_to(mail_to)
|
||||
self.set_headers(headers)
|
||||
self.set_html(html=html) # , url=self.html_from_url)
|
||||
self.set_text(text=text) # , url=self.text_from_url)
|
||||
self.set_html(html=html)
|
||||
self.set_text(text=text)
|
||||
self.render_data = {}
|
||||
self._dkim_signer = None
|
||||
self.after_build = None
|
||||
|
||||
if attachments:
|
||||
for a in attachments:
|
||||
self.attachments.add(a)
|
||||
|
||||
self.after_build = None
|
||||
|
||||
def set_mail_from(self, mail_from):
|
||||
# In: ('Alice', '<alice@me.com>' )
|
||||
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):
|
||||
# Now we parse only one to-addr
|
||||
# TODO: parse list of to-addrs
|
||||
|
@ -121,25 +116,40 @@ class Message(object):
|
|||
self._html = html
|
||||
self._html_url = url
|
||||
|
||||
def get_html(self):
|
||||
return self._html
|
||||
|
||||
html = property(get_html, set_html)
|
||||
|
||||
def set_text(self, text, url=None):
|
||||
if hasattr(text, 'read'):
|
||||
text = text.read()
|
||||
self._text = text
|
||||
self._text_url = url
|
||||
|
||||
def attach(self, **kwargs):
|
||||
if 'content_disposition' not in kwargs:
|
||||
kwargs['content_disposition'] = 'attachment'
|
||||
self.attachments.add(kwargs)
|
||||
def get_text(self):
|
||||
return self._text
|
||||
|
||||
text = property(get_text, set_text)
|
||||
|
||||
@classmethod
|
||||
def from_loader(cls, loader, template_cls=None, **kwargs):
|
||||
"""
|
||||
Get html and attachments from HTTPLoader
|
||||
Get html and attachments from Loader
|
||||
"""
|
||||
message = cls(html=template_cls and template_cls(loader.html) or loader.html, **kwargs)
|
||||
for att in loader.filestore:
|
||||
message.attach(**att.as_dict())
|
||||
|
||||
html = loader.html
|
||||
if html and template_cls:
|
||||
html = template_cls(html)
|
||||
|
||||
text = loader.text
|
||||
if text and template_cls:
|
||||
text = template_cls(text)
|
||||
|
||||
message = cls(html=html, text=text, **kwargs)
|
||||
|
||||
for attachment in loader.attachments:
|
||||
message.attach(**attachment.as_dict())
|
||||
return message
|
||||
|
||||
@property
|
||||
|
@ -164,12 +174,6 @@ class Message(object):
|
|||
def render(self, **kwargs):
|
||||
self.render_data = kwargs
|
||||
|
||||
@property
|
||||
def attachments(self):
|
||||
if self._attachments is None:
|
||||
self._attachments = self.filestore_cls(self.attachment_cls)
|
||||
return self._attachments
|
||||
|
||||
def set_date(self, value):
|
||||
if isinstance(value, string_types):
|
||||
_d = dateutil_parse(value)
|
||||
|
@ -197,13 +201,7 @@ class Message(object):
|
|||
return is_callable(mid) and mid() or mid
|
||||
|
||||
def encode_header(self, value):
|
||||
value = to_unicode(value, charset=self.charset)
|
||||
if isinstance(value, string_types):
|
||||
value = value.rstrip()
|
||||
_r = Header(value, self.charset)
|
||||
return str(_r)
|
||||
else:
|
||||
return value
|
||||
return encode_header_(value, self.charset)
|
||||
|
||||
def encode_name_header(self, realname, email):
|
||||
if realname:
|
||||
|
@ -222,18 +220,29 @@ class Message(object):
|
|||
if '\n' in value or '\r' in value:
|
||||
raise BadHeaderError("Header values can't contain newlines (got %r for header %r)" % (value, key))
|
||||
|
||||
if key.lower() in ADDRESS_HEADERS:
|
||||
if key.lower() in self.ADDRESS_HEADERS:
|
||||
value = ', '.join(sanitize_address(addr, self.charset)
|
||||
for addr in getaddresses((value,)))
|
||||
|
||||
msg[key] = encode and self.encode_header(value) or value
|
||||
|
||||
@property
|
||||
def attachments(self):
|
||||
if self._attachments is None:
|
||||
self._attachments = self.filestore_cls(self.attachment_cls)
|
||||
return self._attachments
|
||||
|
||||
def attach(self, **kwargs):
|
||||
if 'content_disposition' not in kwargs:
|
||||
kwargs['content_disposition'] = 'attachment'
|
||||
self.attachments.add(kwargs)
|
||||
|
||||
def _build_message(self, message_cls=None):
|
||||
|
||||
message_cls = message_cls or SafeMIMEMultipart
|
||||
msg = message_cls()
|
||||
|
||||
msg.preamble = ROOT_PREAMBLE
|
||||
msg.preamble = self.ROOT_PREAMBLE
|
||||
|
||||
self.set_header(msg, 'Date', self.message_date, encode=False)
|
||||
self.set_header(msg, 'Message-ID', self.message_id(), encode=False)
|
||||
|
@ -255,8 +264,11 @@ class Message(object):
|
|||
mail_to = self._mail_to and self.encode_name_header(*self._mail_to[0]) or None
|
||||
self.set_header(msg, 'To', mail_to, encode=False)
|
||||
|
||||
msgrel = SafeMIMEMultipart('related')
|
||||
msg.attach(msgrel)
|
||||
|
||||
msgalt = SafeMIMEMultipart('alternative')
|
||||
msg.attach(msgalt)
|
||||
msgrel.attach(msgalt)
|
||||
|
||||
_text = self.text_body
|
||||
_html = self.html_body
|
||||
|
@ -275,34 +287,23 @@ class Message(object):
|
|||
msgalt.attach(msghtml)
|
||||
|
||||
for f in self.attachments:
|
||||
msgfile = f.mime
|
||||
if msgfile:
|
||||
msg.attach(msgfile)
|
||||
part = f.mime
|
||||
if part:
|
||||
if f.is_inline:
|
||||
msgrel.attach(part)
|
||||
else:
|
||||
msg.attach(part)
|
||||
|
||||
if self.after_build:
|
||||
self.after_build(self, msg)
|
||||
|
||||
return msg
|
||||
|
||||
def message(self, message_cls=None):
|
||||
msg = self._build_message(message_cls=message_cls)
|
||||
if self._dkim_signer:
|
||||
msg_str = msg.as_string()
|
||||
dkim_header = self._dkim_signer.get_sign_header(to_bytes(msg_str))
|
||||
if dkim_header:
|
||||
msg._headers.insert(0, dkim_header)
|
||||
return msg
|
||||
|
||||
def as_string(self):
|
||||
# self.as_string() is not equialent self.message().as_string()
|
||||
# self.as_string() gets one less message-to-string conversions for dkim
|
||||
msg = self._build_message()
|
||||
r = msg.as_string()
|
||||
if self._dkim_signer:
|
||||
dkim_header = self._dkim_signer.get_sign(to_bytes(r))
|
||||
if dkim_header:
|
||||
r = dkim_header + r
|
||||
return r
|
||||
class MessageSendMixin(object):
|
||||
|
||||
smtp_pool_factory = ObjectFactory
|
||||
smtp_cls = SMTPBackend
|
||||
|
||||
@property
|
||||
def smtp_pool(self):
|
||||
|
@ -311,9 +312,6 @@ class Message(object):
|
|||
pool = self._smtp_pool = self.smtp_pool_factory(cls=self.smtp_cls)
|
||||
return pool
|
||||
|
||||
def dkim(self, **kwargs):
|
||||
self._dkim_signer = self.dkim_cls(**kwargs)
|
||||
|
||||
def send(self,
|
||||
to=None,
|
||||
set_mail_to=True,
|
||||
|
@ -361,7 +359,7 @@ class Message(object):
|
|||
from_addr = self._mail_from[1]
|
||||
|
||||
if not from_addr:
|
||||
raise ValueError('No from-addr')
|
||||
raise ValueError('No "from" addr')
|
||||
|
||||
params = dict(from_addr=from_addr,
|
||||
to_addrs=[to_addr, ],
|
||||
|
@ -376,6 +374,105 @@ class Message(object):
|
|||
return response[0]
|
||||
|
||||
|
||||
class MessageTransformerMixin(object):
|
||||
|
||||
transformer_cls = None
|
||||
|
||||
def create_transformer(self, **kw):
|
||||
cls = self.transformer_cls
|
||||
if cls is None:
|
||||
from emails.transformer import MessageTransformer
|
||||
cls = MessageTransformer
|
||||
|
||||
self._transformer = cls(message=self, **kw)
|
||||
return self._transformer
|
||||
|
||||
def destroy_transformer(self):
|
||||
self._transformer = None
|
||||
|
||||
@property
|
||||
def transformer(self):
|
||||
t = getattr(self, '_transformer', None)
|
||||
if t is None:
|
||||
t = self.create_transformer()
|
||||
return t
|
||||
|
||||
|
||||
class Message(BaseMessage, MessageSendMixin, MessageTransformerMixin):
|
||||
"""
|
||||
Email message with:
|
||||
- DKIM signer
|
||||
- smtp send
|
||||
- Message.transformer object
|
||||
"""
|
||||
|
||||
dkim_cls = DKIMSigner
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
BaseMessage.__init__(self, **kwargs)
|
||||
self._dkim_signer = None
|
||||
self.after_build = None
|
||||
|
||||
def dkim(self, **kwargs):
|
||||
self._dkim_signer = self.dkim_cls(**kwargs)
|
||||
|
||||
def set_html(self, **kw):
|
||||
# When html set, remove old transformer
|
||||
self.destroy_transformer()
|
||||
super(Message, self).set_html(**kw)
|
||||
|
||||
def as_message(self, message_cls=None):
|
||||
msg = self._build_message(message_cls=message_cls)
|
||||
if self._dkim_signer:
|
||||
msg_str = msg.as_string()
|
||||
dkim_header = self._dkim_signer.get_sign_header(to_bytes(msg_str))
|
||||
if dkim_header:
|
||||
msg._headers.insert(0, dkim_header)
|
||||
return msg
|
||||
|
||||
message = as_message
|
||||
|
||||
def as_string(self):
|
||||
# self.as_string() is not equialent self.message().as_string()
|
||||
# self.as_string() gets one less message-to-string conversions for dkim
|
||||
msg = self._build_message()
|
||||
r = msg.as_string()
|
||||
if self._dkim_signer:
|
||||
dkim_header = self._dkim_signer.get_sign(to_bytes(r))
|
||||
if dkim_header:
|
||||
r = dkim_header + r
|
||||
return r
|
||||
|
||||
|
||||
def html(**kwargs):
|
||||
return Message(**kwargs)
|
||||
|
||||
|
||||
class DjangoMessageProxy(object):
|
||||
|
||||
"""
|
||||
Class looks like django.core.mail.EmailMessage for standard django email backend.
|
||||
|
||||
Example usage:
|
||||
|
||||
message = emails.Message(html='...', subject='...', mail_from='robot@company.ltd')
|
||||
connection = django.core.mail.get_connection()
|
||||
|
||||
message.set_mail_to('somebody@somewhere.net')
|
||||
connection.send_messages([DjangoMessageProxy(message), ])
|
||||
"""
|
||||
|
||||
def __init__(self, message, recipients=None, context=None):
|
||||
self._message = message
|
||||
self._recipients = recipients
|
||||
self._context = context and context.copy() or {}
|
||||
|
||||
self.from_email = message.mail_from[1]
|
||||
self.encoding = message.charset
|
||||
|
||||
def recipients(self):
|
||||
return self._recipients or [r[1] for r in self._message.mail_to]
|
||||
|
||||
def message(self):
|
||||
self._message.render(**self._context)
|
||||
return self._message.message()
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
# encoding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
|
||||
__all__ = [ 'SMTPSender' ]
|
||||
__all__ = ['SMTPBackend']
|
||||
|
||||
import smtplib
|
||||
import logging
|
||||
import threading
|
||||
from functools import wraps
|
||||
|
||||
from .client import SMTPResponse, SMTPClientWithResponse, SMTPClientWithResponse_SSL
|
||||
|
@ -31,25 +30,19 @@ class SMTPBackend:
|
|||
|
||||
|
||||
def __init__(self,
|
||||
user=None,
|
||||
password=None,
|
||||
ssl=False,
|
||||
tls=False,
|
||||
debug=False,
|
||||
fail_silently=True,
|
||||
**kwargs):
|
||||
|
||||
self.smtp_cls = ssl and self.connection_ssl_cls or self.connection_cls
|
||||
self.debug = debug
|
||||
|
||||
self.ssl = ssl
|
||||
self.tls = tls
|
||||
self.tls = kwargs.get('tls')
|
||||
if self.ssl and self.tls:
|
||||
raise ValueError(
|
||||
"ssl/tls are mutually exclusive, so only set "
|
||||
"one of those settings to True.")
|
||||
|
||||
self.user = user
|
||||
self.password = password
|
||||
if 'timeout' not in kwargs:
|
||||
kwargs['timeout'] = self.DEFAULT_SOCKET_TIMEOUT
|
||||
self.smtp_cls_kwargs = kwargs
|
||||
|
@ -59,10 +52,8 @@ class SMTPBackend:
|
|||
self.fail_silently = fail_silently
|
||||
self.connection = None
|
||||
#self.local_hostname=DNS_NAME.get_fqdn()
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def open(self):
|
||||
#logger.debug('SMTPSender _connect')
|
||||
if self.connection is None:
|
||||
self.connection = self.smtp_cls(parent=self, **self.smtp_cls_kwargs)
|
||||
self.connection.initialize()
|
||||
|
@ -83,7 +74,6 @@ class SMTPBackend:
|
|||
finally:
|
||||
self.connection = None
|
||||
|
||||
|
||||
def make_response(self, exception=None):
|
||||
return self.response_cls(host=self.host, port=self.port, exception=exception)
|
||||
|
||||
|
@ -105,21 +95,17 @@ class SMTPBackend:
|
|||
|
||||
def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]):
|
||||
|
||||
if not to_addrs: return False
|
||||
if not to_addrs:
|
||||
return False
|
||||
|
||||
if not isinstance(to_addrs, (list, tuple)):
|
||||
to_addrs = [to_addrs, ]
|
||||
|
||||
#from_addr = sanitize_address(from_addr, email_message.encoding)
|
||||
#to_addrs = [sanitize_address(addr, email_message.encoding) for addr in to_addrs]
|
||||
#message = email_message.message()
|
||||
#charset = message.get_charset().get_output_charset() if message.get_charset() else 'utf-8'
|
||||
|
||||
try:
|
||||
self.open()
|
||||
except (IOError, smtplib.SMTPException) as e:
|
||||
logger.exception("Error connecting smtp server")
|
||||
response = self.make_response(exception = e)
|
||||
response = self.make_response(exception=e)
|
||||
if not self.fail_silently:
|
||||
response.raise_if_needed()
|
||||
return [response, ]
|
||||
|
@ -133,7 +119,6 @@ class SMTPBackend:
|
|||
rcpt_options=rcpt_options)
|
||||
|
||||
if not self.fail_silently:
|
||||
[ r.raise_if_needed() for r in response ]
|
||||
[r.raise_if_needed() for r in response]
|
||||
|
||||
return response
|
||||
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
# encoding: utf-8
|
||||
|
||||
__all__ = [ 'SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL' ]
|
||||
__all__ = ['SMTPResponse', 'SMTPClientWithResponse', 'SMTPClientWithResponse_SSL']
|
||||
|
||||
from smtplib import _have_ssl, SMTP
|
||||
import smtplib
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -17,7 +15,6 @@ class SMTPResponse(object):
|
|||
self.ssl = ssl
|
||||
self.responses = []
|
||||
self.exception = exception
|
||||
#self.complete = False
|
||||
self.success = None
|
||||
self.from_addr = None
|
||||
self.esmtp_opts = None
|
||||
|
@ -28,7 +25,7 @@ class SMTPResponse(object):
|
|||
self.last_command = None
|
||||
|
||||
def set_status(self, command, code, text):
|
||||
self.responses.append( [command, code, text] )
|
||||
self.responses.append([command, code, text])
|
||||
self.status_code = code
|
||||
self.status_text = text
|
||||
self.last_command = command
|
||||
|
@ -36,7 +33,7 @@ class SMTPResponse(object):
|
|||
def set_exception(self, exc):
|
||||
self.exception = exc
|
||||
|
||||
def raise_if_needed():
|
||||
def raise_if_needed(self):
|
||||
if self.exception:
|
||||
raise self.exception
|
||||
|
||||
|
@ -49,9 +46,6 @@ class SMTPResponse(object):
|
|||
self.status_text.__repr__())
|
||||
|
||||
|
||||
#class SMTPCommandsLog:
|
||||
|
||||
|
||||
class SMTPClientWithResponse(SMTP):
|
||||
|
||||
def __init__(self, parent, **kwargs):
|
||||
|
@ -59,19 +53,17 @@ class SMTPClientWithResponse(SMTP):
|
|||
self.make_response = parent.make_response
|
||||
self._last_smtp_response = (None, None)
|
||||
self.tls = kwargs.pop('tls', False)
|
||||
self.debug = kwargs.pop('debug', False)
|
||||
self.ssl = kwargs.pop('ssl', False)
|
||||
self.debug = kwargs.pop('debug', 0)
|
||||
self.set_debuglevel(self.debug)
|
||||
self.user = kwargs.pop('user', None)
|
||||
self.password = kwargs.pop('password', None)
|
||||
SMTP.__init__(self, **kwargs)
|
||||
self.initialize()
|
||||
|
||||
def initialize(self):
|
||||
if self.debug:
|
||||
self.set_debuglevel(1)
|
||||
if self.tls:
|
||||
self.ehlo()
|
||||
self.starttls()
|
||||
self.ehlo()
|
||||
if self.user:
|
||||
self.login(user=self.user, password=self.password)
|
||||
self.ehlo_or_helo_if_needed()
|
||||
|
@ -92,7 +84,6 @@ class SMTPClientWithResponse(SMTP):
|
|||
self._last_smtp_response = (code, msg)
|
||||
return code, msg
|
||||
|
||||
|
||||
def _send_one_mail(self, from_addr, to_addr, msg, mail_options=[], rcpt_options=[]):
|
||||
|
||||
esmtp_opts = []
|
||||
|
@ -140,18 +131,17 @@ class SMTPClientWithResponse(SMTP):
|
|||
return response
|
||||
|
||||
def sendmail(self, from_addr, to_addrs, msg, mail_options=[], rcpt_options=[]):
|
||||
|
||||
# Send one email and returns one response
|
||||
if not to_addrs:
|
||||
raise StopIteration
|
||||
return []
|
||||
|
||||
assert isinstance(to_addrs, (list, tuple))
|
||||
|
||||
if len(to_addrs)>1:
|
||||
logger.warning('Beware: emails.smtp.client.SMTPClientWithResponse.sendmail sends full message to each email')
|
||||
|
||||
return [ self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \
|
||||
for to_addr in to_addrs ]
|
||||
return [self._send_one_mail(from_addr, to_addr, msg, mail_options, rcpt_options) \
|
||||
for to_addr in to_addrs]
|
||||
|
||||
|
||||
|
||||
|
@ -160,18 +150,34 @@ if _have_ssl:
|
|||
from smtplib import SMTP_SSL
|
||||
import ssl
|
||||
|
||||
class SMTPClientWithResponse_SSL(SMTPClientWithResponse, SMTP_SSL):
|
||||
class SMTPClientWithResponse_SSL(SMTP_SSL, SMTPClientWithResponse):
|
||||
|
||||
def __init__(self, **kw):
|
||||
args = {}
|
||||
for k in ('host', 'port', 'local_hostname', 'keyfile', 'certfile', 'timeout'):
|
||||
if k in kw:
|
||||
args[k] = kw[k]
|
||||
SMTP_SSL.__init__(self, **args)
|
||||
SMTPClientWithResponse.__init__(self, **kw)
|
||||
|
||||
def data(self, msg):
|
||||
(code, msg) = SMTP.data(self, msg)
|
||||
self._last_smtp_response = (code, msg)
|
||||
return code, msg
|
||||
|
||||
def quit(self):
|
||||
"""Closes the connection to the email server."""
|
||||
try:
|
||||
super(self, SMTPClientWithResponse_SSL).quit()
|
||||
SMTPClientWithResponse.quit(self)
|
||||
except (ssl.SSLError, smtplib.SMTPServerDisconnected):
|
||||
# This happens when calling quit() on a TLS connection
|
||||
# sometimes, or when the connection was already disconnected
|
||||
# by the server.
|
||||
self.close()
|
||||
|
||||
def sendmail(self, *args, **kw):
|
||||
return SMTPClientWithResponse.sendmail(self, *args, **kw)
|
||||
|
||||
else:
|
||||
|
||||
class SMTPClientWithResponse_SSL:
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
def simple_dict2str(d):
|
||||
# Simple dict serializer
|
||||
return ";".join( [ "%s=%s" % (k, v) for (k, v) in d.items() ] )
|
||||
return ";".join(["%s=%s" % (k, v) for (k, v) in d.items()])
|
||||
|
||||
_serializer = simple_dict2str
|
||||
|
||||
|
|
|
@ -10,8 +10,11 @@ import requests
|
|||
from mimetypes import guess_type
|
||||
from email.mime.base import MIMEBase
|
||||
from email.encoders import encode_base64
|
||||
import emails
|
||||
from emails.compat import urlparse
|
||||
from emails.compat import string_types, to_bytes
|
||||
from emails.utils import fetch_url, encode_header
|
||||
|
||||
|
||||
# class FileNotFound(Exception):
|
||||
# pass
|
||||
|
@ -32,6 +35,8 @@ class BaseFile(object):
|
|||
Store base "attachment-file" information.
|
||||
"""
|
||||
|
||||
content_id_suffix = '@python.emails'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
uri and filename are connected properties.
|
||||
|
@ -42,12 +47,11 @@ class BaseFile(object):
|
|||
self.absolute_url = kwargs.get('absolute_url', None) or self.uri
|
||||
self.filename = kwargs.get('filename', None)
|
||||
self.data = kwargs.get('data', None)
|
||||
self._mime_type = kwargs.get('mime_type', None)
|
||||
self._headers = kwargs.get('headers', None)
|
||||
self._content_disposition = kwargs.get('content_disposition', None)
|
||||
self.subtype = kwargs.get('subtype', None)
|
||||
self.local_loader = kwargs.get('local_loader', None)
|
||||
self.id = id
|
||||
self._mime_type = kwargs.get('mime_type')
|
||||
self._headers = kwargs.get('headers')
|
||||
self._content_disposition = kwargs.get('content_disposition', 'attachment')
|
||||
self.subtype = kwargs.get('subtype')
|
||||
self.local_loader = kwargs.get('local_loader')
|
||||
|
||||
def as_dict(self, fields=None):
|
||||
fields = fields or ('uri', 'absolute_url', 'filename', 'data',
|
||||
|
@ -119,21 +123,41 @@ class BaseFile(object):
|
|||
|
||||
content_disposition = property(get_content_disposition, set_content_disposition)
|
||||
|
||||
@property
|
||||
def is_inline(self):
|
||||
return self.content_disposition == 'inline'
|
||||
|
||||
@is_inline.setter
|
||||
def is_inline(self, value):
|
||||
if bool(value):
|
||||
self.content_disposition = 'inline'
|
||||
else:
|
||||
self.content_disposition = 'attachment'
|
||||
|
||||
@property
|
||||
def content_id(self):
|
||||
return "{0}{1}".format(self.filename, self.content_id_suffix)
|
||||
|
||||
@staticmethod
|
||||
def parse_content_id(cls, content_id):
|
||||
if content_id.endswith(cls.content_id_suffix):
|
||||
return {'filename': content_id[:-len(cls.content_id_suffix)]}
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def mime(self):
|
||||
if self.content_disposition is None:
|
||||
return None
|
||||
_mime = getattr(self, '_cached_mime', None)
|
||||
if _mime is None:
|
||||
filename = str(Header(self.filename, 'utf-8'))
|
||||
self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1))
|
||||
filename_header = encode_header(self.filename)
|
||||
self._cached_mime = _mime = MIMEBase(*self.mime_type.split('/', 1), name=filename_header)
|
||||
_mime.set_payload(to_bytes(self.data))
|
||||
encode_base64(_mime)
|
||||
_mime.add_header('Content-Disposition',
|
||||
self.content_disposition,
|
||||
filename=filename)
|
||||
_mime.add_header('Content-Disposition', self.content_disposition, filename=filename_header)
|
||||
if self.content_disposition == 'inline':
|
||||
_mime.add_header('Content-ID', '<{0}>'.format(filename))
|
||||
_mime.add_header('Content-ID', '<%s>' % self.content_id)
|
||||
return _mime
|
||||
|
||||
def reset_mime(self):
|
||||
|
@ -145,11 +169,9 @@ class BaseFile(object):
|
|||
|
||||
class LazyHTTPFile(BaseFile):
|
||||
|
||||
def __init__(self, fetch_params=None, **kwargs):
|
||||
def __init__(self, requests_args=None, **kwargs):
|
||||
BaseFile.__init__(self, **kwargs)
|
||||
self.fetch_params = dict(allow_redirects=True, verify=False)
|
||||
if fetch_params:
|
||||
self.fetch_params.update(fetch_params)
|
||||
self.requests_args = requests_args
|
||||
self._fetched = False
|
||||
|
||||
def fetch(self):
|
||||
|
@ -162,7 +184,7 @@ class LazyHTTPFile(BaseFile):
|
|||
self._data = data
|
||||
return
|
||||
|
||||
r = requests.get(self.absolute_url or self.uri, **self.fetch_params)
|
||||
r = fetch_url(url=self.absolute_url or self.uri, requests_args=self.requests_args)
|
||||
if r.status_code == 200:
|
||||
self._data = r.content
|
||||
self._headers = r.headers
|
||||
|
|
|
@ -18,7 +18,7 @@ class MemoryFileStore(FileStore):
|
|||
if file_cls:
|
||||
self.file_cls = file_cls
|
||||
self._files = OrderedDict()
|
||||
self._filenames = set()
|
||||
self._filenames = {}
|
||||
|
||||
def __contains__(self, k):
|
||||
if isinstance(k, self.file_cls):
|
||||
|
@ -48,14 +48,12 @@ class MemoryFileStore(FileStore):
|
|||
if v:
|
||||
filename = v.filename
|
||||
if filename and (filename in self._filenames):
|
||||
self._filenames.remove(filename)
|
||||
del self._filenames[filename]
|
||||
del self._files[uri]
|
||||
|
||||
def unique_filename(self, filename):
|
||||
|
||||
if filename not in self._filenames:
|
||||
return filename
|
||||
def unique_filename(self, filename, uri=None):
|
||||
|
||||
if filename in self._filenames:
|
||||
n = 1
|
||||
basefilename, ext = splitext(filename)
|
||||
|
||||
|
@ -63,9 +61,13 @@ class MemoryFileStore(FileStore):
|
|||
n += 1
|
||||
filename = "%s-%d%s" % (basefilename, n, ext)
|
||||
if filename not in self._filenames:
|
||||
break
|
||||
else:
|
||||
self._filenames[filename] = uri
|
||||
|
||||
return filename
|
||||
|
||||
def add(self, value):
|
||||
def add(self, value, replace=False):
|
||||
|
||||
if isinstance(value, self.file_cls):
|
||||
uri = value.uri
|
||||
|
@ -75,24 +77,35 @@ class MemoryFileStore(FileStore):
|
|||
else:
|
||||
raise ValueError("Unknown file type: %s" % type(value))
|
||||
|
||||
if (uri not in self._files) or replace:
|
||||
self.remove(uri)
|
||||
value.filename = self.unique_filename(value.filename)
|
||||
self._filenames.add(value.filename)
|
||||
value.filename = self.unique_filename(value.filename, uri=uri)
|
||||
self._files[uri] = value
|
||||
return value
|
||||
|
||||
def by_uri(self, uri, synonims=None):
|
||||
def by_uri(self, uri, synonyms=None):
|
||||
r = self._files.get(uri, None)
|
||||
if r:
|
||||
return r
|
||||
if synonims:
|
||||
for _uri in synonims:
|
||||
if synonyms:
|
||||
for _uri in synonyms:
|
||||
r = self._files.get(_uri, None)
|
||||
if r:
|
||||
return r
|
||||
return None
|
||||
|
||||
def by_filename(self, filename):
|
||||
uri = self._filenames.get(filename)
|
||||
if uri:
|
||||
return self.by_uri(uri)
|
||||
|
||||
def by_content_id(self, content_id):
|
||||
parsed = self.file_cls.parse_content_id(content_id)
|
||||
if parsed:
|
||||
return self.by_filename(parsed['filename'])
|
||||
|
||||
def __getitem__(self, uri):
|
||||
return self._files.get(uri, None)
|
||||
return self.by_uri(uri) or self.by_filename(uri)
|
||||
|
||||
def __iter__(self):
|
||||
for k in self._files:
|
||||
|
|
|
@ -7,7 +7,7 @@ import logging
|
|||
import threading
|
||||
import os
|
||||
import os.path
|
||||
|
||||
import datetime
|
||||
import pytest
|
||||
|
||||
|
||||
|
@ -98,10 +98,82 @@ def smtp_server(request):
|
|||
def django_email_backend(request):
|
||||
from django.conf import settings
|
||||
logger.debug('django_email_backend...')
|
||||
server = smtp_server(request)
|
||||
settings.configure(EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend',
|
||||
EMAIL_HOST=server.host, EMAIL_PORT=server.port)
|
||||
settings.configure(EMAIL_BACKEND='django.core.mail.backends.filebased.EmailBackend',
|
||||
EMAIL_FILE_PATH='tmp-emails')
|
||||
from django.core.mail import get_connection
|
||||
SETTINGS = {}
|
||||
return get_connection()
|
||||
|
||||
|
||||
class SMTPTestParams:
|
||||
|
||||
subject_prefix = '[test-python-emails]'
|
||||
|
||||
def __init__(self, from_email=None, to_email=None, defaults=None, **kw):
|
||||
params = {}
|
||||
params.update(defaults or {})
|
||||
params.update(kw)
|
||||
params['debug'] = 1
|
||||
params['timeout'] = 15
|
||||
self.params = params
|
||||
|
||||
self.from_email = from_email
|
||||
self.to_email = to_email
|
||||
|
||||
def patch_message(self, message):
|
||||
# Some SMTP requires from and to emails
|
||||
|
||||
if self.from_email:
|
||||
message._mail_from = (message._mail_from[0], self.from_email)
|
||||
|
||||
if self.to_email:
|
||||
message.mail_to = self.to_email
|
||||
|
||||
# TODO: this code breaks template in subject; deal with this
|
||||
message.subject = " ".join([self.subject_prefix, datetime.datetime.now().strftime('%H:%M:%S'),
|
||||
message.subject])
|
||||
|
||||
def __str__(self):
|
||||
return u'SMTPTestParams(host={0}, port={1}, user={2})'.format(self.params.get('host'),
|
||||
self.params.get('port'),
|
||||
self.params.get('user'))
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def smtp_servers(request):
|
||||
|
||||
r = []
|
||||
|
||||
"""
|
||||
r.append(SMTPTestParams(from_email='drlavr@yandex.ru',
|
||||
to_email='drlavr@yandex.ru',
|
||||
fail_silently=False,
|
||||
**{'host': 'mx.yandex.ru', 'port': 25, 'ssl': False}))
|
||||
|
||||
r.append(SMTPTestParams(from_email='drlavr+togmail@yandex.ru',
|
||||
to_email='s.lavrinenko@gmail.com',
|
||||
fail_silently=False,
|
||||
**{'host': 'gmail-smtp-in.l.google.com', 'port': 25, 'ssl': False}))
|
||||
|
||||
|
||||
r.append(SMTPTestParams(from_email='drlavr@yandex.ru',
|
||||
to_email='s.lavrinenko@me.com',
|
||||
fail_silently=False,
|
||||
**{'host': 'mx3.mail.icloud.com', 'port': 25, 'ssl': False}))
|
||||
"""
|
||||
|
||||
r.append(SMTPTestParams(from_email='drlavr@yandex.ru',
|
||||
to_email='lavr@outlook.com',
|
||||
fail_silently=False,
|
||||
**{'host': 'mx1.hotmail.com', 'port': 25, 'ssl': False}))
|
||||
|
||||
try:
|
||||
from .local_smtp_settings import SMTP_SETTINGS_WITH_AUTH, FROM_EMAIL, TO_EMAIL
|
||||
r.append(SMTPTestParams(from_email=FROM_EMAIL,
|
||||
to_email=TO_EMAIL,
|
||||
fail_silently=False,
|
||||
**SMTP_SETTINGS_WITH_AUTH))
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return r
|
|
@ -1,6 +1,7 @@
|
|||
# encoding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
import emails
|
||||
import emails.message
|
||||
|
||||
|
||||
def test_send_via_django_backend(django_email_backend):
|
||||
|
@ -9,7 +10,7 @@ def test_send_via_django_backend(django_email_backend):
|
|||
Send email via django's email backend.
|
||||
`django_email_backend` defined in conftest.py
|
||||
"""
|
||||
message_params = {'html':'<p>Test from python-emails',
|
||||
message_params = {'html': '<p>Test from python-emails',
|
||||
'mail_from': 's@lavr.me',
|
||||
'mail_to': 's.lavrinenko@gmail.com',
|
||||
'subject': 'Test from python-emails'}
|
||||
|
@ -22,3 +23,12 @@ def test_send_via_django_backend(django_email_backend):
|
|||
headers = {'Reply-To': 'another@example.com'})
|
||||
backend.send_messages([email, ])
|
||||
|
||||
|
||||
def test_django_message_proxy(django_email_backend):
|
||||
|
||||
message_params = {'html': '<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), ])
|
|
@ -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>
|
||||
|
|
@ -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;"> </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>
|
||||
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 484 B |
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 173 B |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
Before Width: | Height: | Size: 690 B After Width: | Height: | Size: 690 B |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 558 B After Width: | Height: | Size: 558 B |
Before Width: | Height: | Size: 504 B After Width: | Height: | Size: 504 B |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 43 B After Width: | Height: | Size: 43 B |
|
@ -156,7 +156,7 @@
|
|||
City, State<br />
|
||||
99999<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>
|
||||
</td>
|
||||
</tr>
|
|
@ -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><p></p>
|
||||
<p style="color: red;"><p> with inline style: "color: red"</p>
|
||||
<p id="x" style="color: red;">p#x with inline style: "color: red"</p>
|
||||
<div>a <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"><p></p>
|
||||
<p style="color: red;margin: 0;font-size: 2em"><p> 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 <div> 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__())
|
||||
|
||||
|
|
@ -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()
|
|
@ -1,49 +1,31 @@
|
|||
# coding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from emails.loader import cssinliner
|
||||
import emails
|
||||
from emails.compat import StringIO
|
||||
from emails.template import JinjaTemplate
|
||||
from emails.compat import NativeStringIO, to_bytes
|
||||
|
||||
TO_EMAIL = 'jbrown@hotmail.tld'
|
||||
FROM_EMAIL = 'robot@company.tld'
|
||||
|
||||
TRAVIS_CI = os.environ.get('TRAVIS')
|
||||
HAS_INTERNET_CONNECTION = not TRAVIS_CI
|
||||
|
||||
def common_email_data(**kwargs):
|
||||
data = {'charset': 'utf-8',
|
||||
'subject': 'Что-то по-русски',
|
||||
'mail_from': ('Максим Иванов', 'ivanov@ya.ru'),
|
||||
'mail_to': ('Полина Сергеева', 'polina@mail.ru'),
|
||||
'html': '<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):
|
||||
def common_email_data(**kw):
|
||||
T = JinjaTemplate
|
||||
data = {'charset': 'utf-8',
|
||||
'subject': T('Hello, {{name}}'),
|
||||
'mail_from': ('Максим Иванов', 'sergei-nko@mail.ru'),
|
||||
'mail_to': ('Полина Сергеева', 'sergei-nko@mail.ru'),
|
||||
'html': T('<h1>Привет, {{name}}!</h1><p>В первых строках...'),
|
||||
'text': T('Привет, {{name}}!\nВ первых строках...'),
|
||||
'subject': T('[python-emails test] Olá {{name}}'),
|
||||
'mail_from': ('LÖVÅS HÅVET', FROM_EMAIL),
|
||||
'mail_to': ('Pestävä erillään', TO_EMAIL),
|
||||
'html': T('<h1>Olá {{name}}!</h1><p>O Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'),
|
||||
'text': T('Olá, {{name}}!\nO Lorem Ipsum é um texto modelo da indústria tipográfica e de impressão.'),
|
||||
'headers': {'X-Mailer': 'python-emails'},
|
||||
'message_id': emails.MessageID(),
|
||||
'attachments': [
|
||||
{'data': 'aaa', 'filename': 'Event.ics', 'content_disposition': 'attachment'},
|
||||
{'data': 'bbb', 'filename': 'Карта.png', 'content_disposition': 'attachment'}
|
||||
{'data': 'aaa', 'filename': 'κατάσχεση.ics'},
|
||||
{'data': 'bbb', 'filename': 'map.png'}
|
||||
]}
|
||||
if kwargs:
|
||||
data.update(kwargs)
|
||||
if kw:
|
||||
data.update(kw)
|
||||
return data
|
|
@ -1,13 +1,7 @@
|
|||
# coding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from emails.loader import cssinliner
|
||||
import emails
|
||||
from emails.compat import StringIO
|
||||
from emails.template import JinjaTemplate
|
||||
from emails.compat import NativeStringIO, to_bytes
|
||||
|
||||
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
# coding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
from __future__ import unicode_literals, print_function
|
||||
|
||||
import emails
|
||||
from emails.compat import StringIO
|
||||
from emails.template import JinjaTemplate
|
||||
from emails.compat import NativeStringIO, to_bytes
|
||||
|
||||
from .helpers import TRAVIS_CI, HAS_INTERNET_CONNECTION, _email_data, common_email_data
|
||||
from emails.compat import to_unicode
|
||||
from .helpers import common_email_data
|
||||
|
||||
|
||||
def test_message_build():
|
||||
|
@ -18,7 +15,6 @@ def test_message_build():
|
|||
def test_property_works():
|
||||
m = emails.Message(subject='A')
|
||||
assert m._subject == 'A'
|
||||
|
||||
m.subject = 'C'
|
||||
assert m._subject == 'C'
|
||||
|
||||
|
@ -34,7 +30,9 @@ def test_after_build():
|
|||
m = emails.Message(**kwargs)
|
||||
m.after_build = my_after_build
|
||||
|
||||
assert AFTER_BUILD_HEADER in m.as_string()
|
||||
s = m.as_string()
|
||||
print("type of message.as_string() is {0}".format(type(s)))
|
||||
assert AFTER_BUILD_HEADER in to_unicode(s, 'utf-8')
|
||||
|
||||
|
||||
# TODO: more tests here
|
||||
|
|
|
@ -1,79 +1,40 @@
|
|||
# coding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from emails.loader import cssinliner
|
||||
import emails
|
||||
from emails.compat import StringIO
|
||||
from emails.template import JinjaTemplate
|
||||
from emails.compat import NativeStringIO, to_bytes
|
||||
import emails.loader
|
||||
|
||||
from .helpers import TRAVIS_CI, HAS_INTERNET_CONNECTION, _email_data, common_email_data
|
||||
from .helpers import HAS_INTERNET_CONNECTION, common_email_data
|
||||
|
||||
try:
|
||||
from local_settings import SMTP_SERVER, SMTP_PORT, SMTP_SSL, SMTP_USER, SMTP_PASSWORD
|
||||
|
||||
SMTP_DATA = {'host': SMTP_SERVER, 'port': SMTP_PORT,
|
||||
'ssl': SMTP_SSL, 'user': SMTP_USER, 'password': SMTP_PASSWORD,
|
||||
'debug': 0}
|
||||
except ImportError:
|
||||
SMTP_DATA = None
|
||||
|
||||
|
||||
def test_send1():
|
||||
URL = 'http://icdn.lenta.ru/images/2013/08/07/14/20130807143836932/top7_597745dde10ef36605a1239b0771ff62.jpg'
|
||||
data = _email_data()
|
||||
data['attachments'] = [emails.store.LazyHTTPFile(uri=URL), ]
|
||||
m = emails.html(**data)
|
||||
m.render(name='Полина')
|
||||
assert m.subject == 'Hello, Полина'
|
||||
if HAS_INTERNET_CONNECTION:
|
||||
r = m.send(smtp=SMTP_DATA)
|
||||
|
||||
|
||||
def test_send3():
|
||||
data = _email_data(subject='[test python-emails] email with attachments')
|
||||
def test_send_attachment(smtp_servers):
|
||||
"""
|
||||
Test email with attachment
|
||||
"""
|
||||
URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/images/gallery.png'
|
||||
data = common_email_data(subject='Single attachment', attachments=[emails.store.LazyHTTPFile(uri=URL), ])
|
||||
m = emails.html(**data)
|
||||
if HAS_INTERNET_CONNECTION:
|
||||
r = m.send(render={'name': u'Полина'}, smtp=SMTP_DATA)
|
||||
for d in smtp_servers:
|
||||
d.patch_message(m)
|
||||
r = m.send(smtp=d.params)
|
||||
|
||||
|
||||
def test_send2():
|
||||
data = _email_data()
|
||||
loader = emails.loader.HTTPLoader(filestore=emails.store.MemoryFileStore())
|
||||
URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html'
|
||||
loader.load_url(URL, css_inline=True, make_links_absolute=True, update_stylesheet=True)
|
||||
data['html'] = loader.html
|
||||
data['attachments'] = loader.attachments_dict
|
||||
loader.save_to_file('test_send2.html')
|
||||
def test_send_with_render(smtp_servers):
|
||||
data = common_email_data(subject='Render with name=John')
|
||||
m = emails.html(**data)
|
||||
m.render(name='Полина')
|
||||
|
||||
if HAS_INTERNET_CONNECTION:
|
||||
r = m.send(smtp=SMTP_DATA)
|
||||
r = m.send(to='s.lavrinenko@gmail.com', smtp=SMTP_DATA)
|
||||
for d in smtp_servers:
|
||||
d.patch_message(m)
|
||||
r = m.send(render={'name': u'John'}, smtp=d.params)
|
||||
|
||||
|
||||
def test_send_inline_images():
|
||||
data = _email_data()
|
||||
loader = emails.loader.HTTPLoader(filestore=emails.store.MemoryFileStore())
|
||||
URL = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html'
|
||||
loader.load_url(URL, css_inline=True, make_links_absolute=True, update_stylesheet=True)
|
||||
for img in loader.iter_image_links():
|
||||
link = img.link
|
||||
file = loader.filestore.by_uri(link, img.link_history)
|
||||
img.link = "cid:%s" % file.filename
|
||||
for file in loader.filestore:
|
||||
file.content_disposition = 'inline'
|
||||
data['html'] = loader.html
|
||||
data['attachments'] = loader.attachments_dict
|
||||
# loader.save_to_file('test_send_inline_images.html')
|
||||
m = emails.html(**data)
|
||||
m.render(name='Полина')
|
||||
|
||||
def test_send_with_inline_images(smtp_servers):
|
||||
url = 'http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html'
|
||||
data = common_email_data(subject='Sample html with inline images')
|
||||
del data['html']
|
||||
m = emails.loader.from_url(url=url, message_params=data, images_inline=True)
|
||||
if HAS_INTERNET_CONNECTION:
|
||||
r = m.send(smtp=SMTP_DATA)
|
||||
if r.status_code != 250:
|
||||
logging.error("Error sending email, response=%s" % r)
|
||||
for d in smtp_servers:
|
||||
d.patch_message(m)
|
||||
r = m.send(smtp=d.params)
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
# encoding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
import emails
|
||||
|
||||
import emails.store
|
||||
|
||||
def test_lazy_http():
|
||||
IMG_URL = 'http://lavr.github.io/python-emails/tests/python-logo.gif'
|
||||
f = emails.store.LazyHTTPFile(uri=IMG_URL)
|
||||
assert f.filename == 'python-logo.gif'
|
||||
assert f.content_disposition is None
|
||||
assert f.content_disposition == 'attachment'
|
||||
assert len(f.data) == 2549
|
||||
|
||||
|
||||
|
@ -20,3 +20,9 @@ def test_store_commons():
|
|||
for (k, v) in orig_file.items():
|
||||
assert v == getattr(stored_file, k)
|
||||
|
||||
def test_store_unique_name():
|
||||
store = emails.store.MemoryFileStore()
|
||||
f1 = store.add({'uri': '/a/c.gif'})
|
||||
assert f1.filename == 'c.gif'
|
||||
f2 = store.add({'uri': '/a/b/c.gif'})
|
||||
assert f2.filename == 'c-2.gif'
|
|
@ -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())
|
|
@ -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')
|
|
@ -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()
|
|
@ -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()
|
|
@ -1,5 +1,8 @@
|
|||
# encoding: utf-8
|
||||
from __future__ import unicode_literals
|
||||
import emails
|
||||
import requests
|
||||
from emails.exc import HTTPLoaderError
|
||||
|
||||
__all__ = ['parse_name_and_email', 'load_email_charsets', 'MessageID']
|
||||
|
||||
|
@ -190,9 +193,27 @@ class SafeMIMEMultipart(MIMEMixin, MIMEMultipart):
|
|||
def __setitem__(self, name, val):
|
||||
MIMEMultipart.__setitem__(self, name, val)
|
||||
|
||||
def test_parse_name_and_email():
|
||||
assert parse_name_and_email('john@smith.me') == ('', 'john@smith.me')
|
||||
assert parse_name_and_email('"John Smith" <john@smith.me>') == \
|
||||
('John Smith', 'john@smith.me')
|
||||
assert parse_name_and_email(['John Smith', 'john@smith.me']) == \
|
||||
('John Smith', 'john@smith.me')
|
||||
|
||||
DEFAULT_REQUESTS_PARAMS = dict(allow_redirects=True,
|
||||
verify=False, timeout=10,
|
||||
headers={'User-Agent': emails.USER_AGENT})
|
||||
|
||||
|
||||
def fetch_url(url, valid_http_codes=(200, ), requests_args=None):
|
||||
args = {}
|
||||
args.update(DEFAULT_REQUESTS_PARAMS)
|
||||
args.update(requests_args or {})
|
||||
r = requests.get(url, **args)
|
||||
if valid_http_codes and (r.status_code not in valid_http_codes):
|
||||
raise HTTPLoaderError('Error loading url: %s. HTTP status: %s' % (url, r.status_code))
|
||||
return r
|
||||
|
||||
|
||||
def encode_header(value, charset='utf-8'):
|
||||
value = to_unicode(value, charset=charset)
|
||||
if isinstance(value, string_types):
|
||||
value = value.rstrip()
|
||||
_r = Header(value, charset)
|
||||
return str(_r)
|
||||
else:
|
||||
return value
|
|
@ -3,3 +3,4 @@ lxml
|
|||
chardet
|
||||
python-dateutil
|
||||
requests
|
||||
premailer
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
--requirement=base.txt
|
||||
--requirement=tests-base.txt
|
||||
|
||||
jinja2
|
||||
mako
|
||||
django==1.6
|
||||
lamson
|
||||
ordereddict
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
--requirement=base.txt
|
||||
--requirement=tests-base.txt
|
||||
|
||||
jinja2
|
||||
mako
|
||||
django
|
||||
lamson
|
|
@ -1,5 +1,4 @@
|
|||
--requirement=base.txt
|
||||
--requirement=tests-base.txt
|
||||
|
||||
jinja2
|
||||
mako
|
||||
django
|
|
@ -1,5 +1,4 @@
|
|||
--requirement=base.txt
|
||||
--requirement=tests-base.txt
|
||||
|
||||
jinja2
|
||||
mako
|
||||
django
|
|
@ -0,0 +1,3 @@
|
|||
jinja2
|
||||
mako
|
||||
pytest
|
|
@ -7,13 +7,15 @@ Simple utility that imports html from url ang print generated rfc822 message to
|
|||
|
||||
Example usage:
|
||||
|
||||
$ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html
|
||||
--subject="Some subject"
|
||||
--from-name="Sergey Lavrinenko"
|
||||
--from-email=s@lavr.me
|
||||
--message-id-domain=localhost
|
||||
--send-test-email-to=sergei-nko@mail.ru
|
||||
--smtp-host=mxs.mail.ru
|
||||
$ python make_rfc822.py --url=http://lavr.github.io/python-emails/tests/campaignmonitor-samples/sample-template/template-widgets.html \
|
||||
--subject="Some subject" \
|
||||
--from-name="Sergey Lavrinenko" \
|
||||
--from-email=s@lavr.me \
|
||||
--message-id-domain=localhost \
|
||||
--add-header="X-Test-Header: Test" \
|
||||
--add-header-imported-from \
|
||||
--send-test-email-to=sergei-nko@mail.ru \
|
||||
--smtp-host=mxs.mail.ru \
|
||||
--smtp-port=25
|
||||
|
||||
Copyright 2013 Sergey Lavrinenko <s@lavr.me>
|
||||
|
@ -32,7 +34,6 @@ from emails.template import JinjaTemplate as T
|
|||
|
||||
|
||||
class MakeRFC822:
|
||||
|
||||
def __init__(self, options):
|
||||
self.options = options
|
||||
|
||||
|
@ -41,9 +42,14 @@ class MakeRFC822:
|
|||
--add-header "X-Source: AAA"
|
||||
"""
|
||||
r = {}
|
||||
if self.options.add_headers:
|
||||
for s in self.options.add_headers:
|
||||
(k, v) = s.split(':', 1)
|
||||
r[k] = v
|
||||
|
||||
if self.options.add_header_imported_from:
|
||||
r['X-Imported-From-URL'] = self.options.url
|
||||
|
||||
return r
|
||||
|
||||
def _get_message(self):
|
||||
|
@ -51,23 +57,19 @@ class MakeRFC822:
|
|||
options = self.options
|
||||
|
||||
if options.message_id_domain:
|
||||
message_id = emails.utils.MessageID(domain=options.message_id_domain)
|
||||
message_id = emails.MessageID(domain=options.message_id_domain)
|
||||
else:
|
||||
message_id = None
|
||||
|
||||
loader = emails.loader.from_url(url=options.url, images_inline=options.inline_images)
|
||||
|
||||
|
||||
message = emails.Message.from_loader(loader=loader,
|
||||
headers= self._headers_from_command_line(), #{'X-Imported-From-URL': options.url },
|
||||
headers=self._headers_from_command_line(),
|
||||
template_cls=T,
|
||||
mail_from=(options.from_name, options.from_email),
|
||||
subject=T(unicode(options.subject, 'utf-8')),
|
||||
message_id=message_id
|
||||
)
|
||||
message_id=message_id)
|
||||
return message
|
||||
|
||||
|
||||
def _send_test_email(self, message):
|
||||
|
||||
options = self.options
|
||||
|
@ -88,9 +90,10 @@ class MakeRFC822:
|
|||
def _start_batch(self):
|
||||
|
||||
fn = self.options.batch
|
||||
if not fn: return None
|
||||
if not fn:
|
||||
return None
|
||||
|
||||
if fn=='-':
|
||||
if fn == '-':
|
||||
f = sys.stdin
|
||||
else:
|
||||
f = open(fn, 'rb')
|
||||
|
@ -98,14 +101,14 @@ class MakeRFC822:
|
|||
def wrapper():
|
||||
for l in f.readlines():
|
||||
l = l.strip()
|
||||
if not l: continue
|
||||
# Magic is here
|
||||
if not l:
|
||||
continue
|
||||
try:
|
||||
# Try to parse line as json
|
||||
yield json.loads(l)
|
||||
except ValueError:
|
||||
# If it is not json, we expect one word with '@' sign
|
||||
assert len(l.split())==1
|
||||
assert len(l.split()) == 1
|
||||
print l
|
||||
login, domain = l.split('@') # ensure there is something email-like
|
||||
yield {'to': l}
|
||||
|
@ -115,7 +118,7 @@ class MakeRFC822:
|
|||
def _generate_batch(self, batch, message):
|
||||
n = 0
|
||||
for values in batch:
|
||||
message.set_mail_to( values['to'] )
|
||||
message.set_mail_to(values['to'])
|
||||
message.render(**values.get('data', {}))
|
||||
s = message.as_string()
|
||||
n += 1
|
||||
|
@ -124,33 +127,23 @@ class MakeRFC822:
|
|||
|
||||
def main(self):
|
||||
|
||||
options = self.options
|
||||
|
||||
message = self._get_message()
|
||||
|
||||
self._send_test_email(message)
|
||||
|
||||
if self.options.batch:
|
||||
batch = self._start_batch()
|
||||
self._generate_batch(batch, message)
|
||||
else:
|
||||
batch = None
|
||||
if self.options.output_format=='eml':
|
||||
if self.options.output_format == 'eml':
|
||||
print(message.as_string())
|
||||
elif self.options.output_format=='html':
|
||||
elif self.options.output_format == 'html':
|
||||
print(message.html_body)
|
||||
|
||||
self._send_test_email(message)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='Simple utility that imports html from url ang print generated rfc822 message to console.')
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Imports html from url ang generate rfc822 message.')
|
||||
|
||||
parser.add_argument("-u", "--url", metavar="URL", dest="url", action="store", default=None, required=True)
|
||||
|
||||
|
@ -160,6 +153,8 @@ if __name__=="__main__":
|
|||
parser.add_argument("--message-id-domain", dest="message_id_domain", default=None, required=True)
|
||||
|
||||
parser.add_argument("--add-header", dest="add_headers", action='append', default=None, required=False)
|
||||
parser.add_argument("--add-header-imported-from", dest="add_header_imported_from", default=False,
|
||||
action="store_true")
|
||||
|
||||
parser.add_argument("--inline-images", action="store_true", dest="inline_images", default=False)
|
||||
|
||||
|
@ -180,6 +175,6 @@ if __name__=="__main__":
|
|||
|
||||
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()
|
||||
|
|
10
setup.py
|
@ -56,15 +56,17 @@ class run_audit(Command):
|
|||
else:
|
||||
print("No problems found in sourcecode.")
|
||||
|
||||
import emails
|
||||
|
||||
settings.update(
|
||||
name='emails',
|
||||
version='0.1.13',
|
||||
version=emails.__version__,
|
||||
description='Elegant and simple email library for python 2/3',
|
||||
long_description=open('README.rst').read(),
|
||||
author='Sergey Lavrinenko',
|
||||
author_email='s@lavr.me',
|
||||
url='https://github.com/lavr/python-emails',
|
||||
packages = ['emails',
|
||||
packages=['emails',
|
||||
'emails.compat',
|
||||
'emails.loader',
|
||||
'emails.store',
|
||||
|
@ -74,8 +76,8 @@ settings.update(
|
|||
'emails.packages.cssselect',
|
||||
'emails.packages.dkim'
|
||||
],
|
||||
scripts=[ 'scripts/make_rfc822.py' ],
|
||||
install_requires = [ 'cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests' ],
|
||||
scripts=['scripts/make_rfc822.py'],
|
||||
install_requires=['cssutils', 'lxml', 'chardet', 'python-dateutil', 'requests', 'premailer'],
|
||||
license=open('LICENSE').read(),
|
||||
#test_suite = "emails.testsuite.test_all",
|
||||
zip_safe=False,
|
||||
|
|
|
@ -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
|