├── .python-version ├── AUTHORS ├── tests ├── fixtures │ ├── email.subject │ ├── email.b.subject │ ├── email.text │ ├── email.b.text │ ├── email_globale.text │ ├── original.txt │ ├── email_inference.xml │ ├── email_inference_raw.html │ ├── email.xml │ ├── email_with_type.xml │ ├── email.raw.html │ ├── email_inference.html │ ├── email_render_with_inference.html │ ├── email.html │ ├── email_en_b.xml │ ├── email_en_default.xml │ ├── email.b.html │ ├── email.rtl.html │ └── email_globale.html ├── src │ ├── ar │ │ ├── global.xml │ │ └── email.xml │ ├── en │ │ ├── global.xml │ │ ├── email_render_with_inference.xml │ │ ├── email_globale.xml │ │ ├── fallback.xml │ │ ├── email_order.xml │ │ ├── placeholder.xml │ │ ├── missing_placeholder.xml │ │ ├── email_subject_resend.xml │ │ ├── email_subjects_ab.xml │ │ └── email.xml │ ├── fr │ │ ├── global.xml │ │ ├── fallback.xml │ │ ├── email.xml │ │ ├── placeholder.xml │ │ └── missing_placeholder.xml │ └── placeholders_config.json ├── templates_html │ ├── transactional │ │ ├── email_render_with_inference.html │ │ └── basic_template.html │ ├── marketing │ │ ├── basic_marketing_template.html │ │ └── globale_template.html │ ├── basic_template.css │ └── sections │ │ └── header-with-background.html ├── test_cmd.py ├── test_placeholder.py ├── test_fs.py ├── test_reader.py ├── test_parser.py └── test_renderer.py ├── requirements.txt ├── setup.cfg ├── email_parser ├── utils.py ├── link_shortener.py ├── const.py ├── config.py ├── placeholder.py ├── markdown_ext.py ├── model.py ├── cmd.py ├── fs.py ├── renderer.py ├── __init__.py └── reader.py ├── .gitignore ├── .travis.yml ├── setup.py ├── Makefile ├── CHANGELOG ├── README.md └── LICENSE /.python-version: -------------------------------------------------------------------------------- 1 | 3.6.8 2 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | ca77y (https://github.com/ca77y) 2 | -------------------------------------------------------------------------------- /tests/fixtures/email.subject: -------------------------------------------------------------------------------- 1 | Dummy subject 2 | -------------------------------------------------------------------------------- /tests/fixtures/email.b.subject: -------------------------------------------------------------------------------- 1 | Awesome subject 2 | -------------------------------------------------------------------------------- /tests/fixtures/email.text: -------------------------------------------------------------------------------- 1 | Dummy content 2 | 3 | Dummy inline 4 | -------------------------------------------------------------------------------- /tests/fixtures/email.b.text: -------------------------------------------------------------------------------- 1 | Awesome content 2 | 3 | Dummy inline 4 | -------------------------------------------------------------------------------- /tests/fixtures/email_globale.text: -------------------------------------------------------------------------------- 1 | Dummy content 2 | 3 | Dummy inline 4 | 5 | Unsubscribe from KeepSafe updates, click here ({{unsubscribe_link}}) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Markdown==2.6.11 2 | beautifulsoup4==4.4.1 3 | cssutils==1.0.1 4 | inlinestyler==0.2.1 5 | lxml==3.5 6 | pystache==0.5.4 7 | parse==1.8.2 8 | -------------------------------------------------------------------------------- /tests/fixtures/original.txt: -------------------------------------------------------------------------------- 1 | Dummy subject 2 | 3 | Dummy content 4 | 5 | ![Alt text](/path/to/img.jpg) 6 | 7 | ![Alt text](http://path.com/to/{link_locale}/img.jpg) 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | max-line-length = 120 6 | ignore = F403, F405, F401 7 | 8 | [pep8] 9 | max-line-length = 120 10 | -------------------------------------------------------------------------------- /email_parser/utils.py: -------------------------------------------------------------------------------- 1 | from . import config 2 | 3 | 4 | def normalize_locale(locale): 5 | if locale in config.lang_mappings: 6 | return config.lang_mappings[locale] 7 | return locale 8 | -------------------------------------------------------------------------------- /tests/src/ar/global.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/src/en/global.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/src/fr/global.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tests/fixtures/email_inference.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tests/src/en/email_render_with_inference.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .noseids 2 | temp/ 3 | dist/ 4 | build/ 5 | target/ 6 | __pycache__/ 7 | *.egg-info 8 | venv/ 9 | *.pyc 10 | 11 | # OSX 12 | .DS_Store 13 | 14 | # Intellij files 15 | .idea/ 16 | *.iml 17 | 18 | # Sublime files 19 | *.sublime-project 20 | *.sublime-workspace 21 | 22 | # VS Code 23 | .vscode 24 | .coverage 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6.5" 4 | # command to install dependencies 5 | install: "pip install -r requirements-dev.txt" 6 | # command to run tests 7 | script: 8 | - flake8 email_parser tests 9 | - nosetests --with-coverage --cover-inclusive --cover-erase --cover-package=email_parser --cover-min-percentage=70 10 | -------------------------------------------------------------------------------- /tests/fixtures/email_inference_raw.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy subject 5 | 6 | 7 | {{bitmap:MY_BITMAP:max-width=160px;max-height=160px;}} 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/fixtures/email.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/templates_html/transactional/email_render_with_inference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{subject}} 5 | 6 | 7 | {{bitmap:MY_BITMAP:max-width=160px;max-height=160px;}} 8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/fixtures/email_with_type.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/fixtures/email.raw.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{subject}} 5 | 6 | 7 |

8 | {{content}} 9 |

10 | {{inline}} 11 | {{image}} 12 | {{image_absolute}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/templates_html/transactional/basic_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{subject}} 5 | 6 | 7 |

8 | {{content}} 9 |

10 | {{inline}} 11 | {{image}} 12 | {{image_absolute}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/templates_html/marketing/basic_marketing_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{subject}} 5 | 6 | 7 |

8 | {{content}} 9 |

10 | {{inline}} 11 | {{image}} 12 | {{image_absolute}} 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/fixtures/email_inference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy subject 5 | 6 | 7 |
8 | ALT_TEXT 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/templates_html/marketing/globale_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{subject}} 5 | 6 | 7 |

8 | {{content}} 9 |

10 | {{inline}} 11 | {{image}} 12 | {{image_absolute}} 13 | {{global_unsubscribe}} 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/src/fr/fallback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject fr 4 | Dummy content fr 5 | [[#C0D9D9]] 6 | 7 | 8 | ![Alt text](http://path.com/to/img-fr.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/fixtures/email_render_with_inference.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Awesome subject 5 | 6 | 7 |
8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /tests/src/fr/email.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject fr 4 | Dummy content fr 5 | [[#C0D9D9]] 6 | Dummy inline fr 7 | ![Alt text](/path/to/img-fr.jpg) 8 | ![Alt text](http://path.com/to/img-fr.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/en/email_globale.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | [[#C0D9D9]] 5 | Dummy content 6 | Dummy inline 7 | ![Alt text](/path/to/img.jpg) 8 | ![Alt text](http://path.com/to/img.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/en/fallback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | [[#C0D9D9]] 5 | Dummy content 6 | Dummy inline 7 | ![Alt text](/path/to/img.jpg) 8 | ![Alt text](http://path.com/to/img.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/fr/placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject fr 4 | Dummy content fr {{placeholder}} 5 | [[#C0D9D9]] 6 | Dummy inline fr 7 | ![Alt text](/path/to/img-fr.jpg) 8 | ![Alt text](http://path.com/to/img-fr.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/ar/email.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | [[#C0D9D9]] 5 | Dummy content 6 | Dummy inline 7 | ![Alt text](/path/to/img.jpg) 8 | ![Alt text](http://path.com/to/{link_locale}/img.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/en/email_order.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | [[#C0D9D9]] 4 | Dummy content 5 | Dummy inline 6 | ![Alt text](/path/to/img.jpg) 7 | ![Alt text](http://path.com/to/{link_locale}/img.jpg) 8 | Dummy subject 9 | 10 | -------------------------------------------------------------------------------- /tests/src/fr/missing_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject fr 4 | Dummy content fr {{placeholder}} 5 | [[#C0D9D9]] 6 | Dummy inline fr 7 | ![Alt text](/path/to/img-fr.jpg) 8 | ![Alt text](http://path.com/to/img-fr.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/templates_html/basic_template.css: -------------------------------------------------------------------------------- 1 | a { 2 | color:#FFFFFF; 3 | font-family: Helvetica, 'Helvetica Neue', Arial, sans-serif; 4 | font-size:1em; 5 | font-weight:bold; 6 | } 7 | 8 | h1 { 9 | font-size: 2.5em; 10 | line-height: 1.25em; 11 | margin:0; 12 | font-weight:200; 13 | color: #cccccc; 14 | background:none; 15 | border:none; 16 | } 17 | 18 | h2 { 19 | margin:0; 20 | font-size: 1.5em; 21 | line-height: 1.25em; 22 | font-weight:200; 23 | color: #cccccc; 24 | background:none; 25 | border:none; 26 | } -------------------------------------------------------------------------------- /tests/src/en/placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | [[#C0D9D9]] 5 | Dummy content {{placeholder}} 6 | Dummy inline 7 | ![Alt text](/path/to/img.jpg) 8 | ![Alt text](http://path.com/to/img.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/src/en/missing_placeholder.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | [[#C0D9D9]] 5 | Dummy content {{placeholder}} 6 | Dummy inline 7 | ![Alt text](/path/to/img.jpg) 8 | ![Alt text](http://path.com/to/img.jpg) 9 | 10 | -------------------------------------------------------------------------------- /tests/fixtures/email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy subject 5 | 6 | 7 |

8 |

Dummy content

9 |

10 | Dummy inline 11 |

12 | Alt text 13 |

14 |

15 | Alt text 16 |

17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/fixtures/email_en_b.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/fixtures/email_en_default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/src/en/email_subject_resend.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | Alt Dummy subject 5 | [[#C0D9D9]] 6 | Dummy content 7 | Dummy inline 8 | ![Alt text](/path/to/img.jpg) 9 | ![Alt text](http://path.com/to/img.jpg) 10 | 11 | -------------------------------------------------------------------------------- /tests/fixtures/email.b.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Awesome subject 5 | 6 | 7 |

8 |

Awesome content

9 |

10 | Dummy inline 11 |

12 | Alt text 13 |

14 |

15 | Alt text 16 |

17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/fixtures/email.rtl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dummy subject 6 | 7 | 8 | 9 |

10 |

11 | Dummy content 12 |

13 |

14 | Dummy inline 15 |

16 | Alt text 17 |

18 |

19 | Alt text 20 |

21 | 22 | 23 | -------------------------------------------------------------------------------- /email_parser/link_shortener.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | 4 | class NullShortener(object): 5 | name = 'null' 6 | 7 | def __init__(self, config): 8 | pass 9 | 10 | def shorten(self, link): 11 | return link 12 | 13 | 14 | class KsShortener(object): 15 | name = 'keepsafe' 16 | url = 'http://4uon.ly/url/' 17 | 18 | def __init__(self, config): 19 | pass 20 | 21 | def shorten(self, link): 22 | # TODO needs auth 23 | # TODO needs perm links 24 | res = requests.post(self.url, data={'url': link}) 25 | return res.text 26 | 27 | 28 | def shortener(config): 29 | return NullShortener(config) 30 | -------------------------------------------------------------------------------- /tests/templates_html/sections/header-with-background.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 |
10 | {{ content_header_bg }} 11 |
17 | 18 | -------------------------------------------------------------------------------- /tests/src/en/email_subjects_ab.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dummy subject 4 | A subject 5 | B subject 6 | [[#C0D9D9]] 7 | Dummy content 8 | Dummy inline 9 | ![Alt text](/path/to/img.jpg) 10 | ![Alt text](http://path.com/to/img.jpg) 11 | 12 | -------------------------------------------------------------------------------- /tests/src/placeholders_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "dummy_email": { 3 | "unsubscribe_link": 1 4 | }, 5 | "email": { 6 | "unsubscribe_link": 1 7 | }, 8 | "email_globale": { 9 | "unsubscribe_link": 1 10 | }, 11 | "email_subject_resend": { 12 | "unsubscribe_link": 1 13 | }, 14 | "email_subjects_ab": { 15 | "unsubscribe_link": 1 16 | }, 17 | "fallback": { 18 | "unsubscribe_link": 1 19 | }, 20 | "missing_placeholder": { 21 | "placeholder": 1, 22 | "unsubscribe_link": 1 23 | }, 24 | "placeholder": { 25 | "placeholder": 1, 26 | "unsubscribe_link": 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /email_parser/const.py: -------------------------------------------------------------------------------- 1 | SUBJECT_EXTENSION = '.subject' 2 | TEXT_EXTENSION = '.text' 3 | HTML_EXTENSION = '.html' 4 | CSS_EXTENSION = '.css' 5 | SOURCE_EXTENSION = '.xml' 6 | SUBJECT_PLACEHOLDER = 'subject' 7 | 8 | GLOBALS_EMAIL_NAME = 'global' 9 | GLOBALS_PLACEHOLDER_PREFIX = 'global_' 10 | REPO_SRC_PATH = 'src' 11 | PLACEHOLDERS_FILENAME = 'placeholders_config.json' 12 | 13 | INLINE_TEXT_PATTERN = r'\[{2}(.+)\]{2}' 14 | IMAGE_PATTERN = '![{}]({}/{})' 15 | SEGMENT_REGEX = r'\]*>' 16 | SEGMENT_NAME_REGEX = r' name="([^"]+)"' 17 | 18 | TEXT_EMAIL_PLACEHOLDER_SEPARATOR = '\n\n' 19 | HTML_PARSER = 'lxml' 20 | LOCALE_PLACEHOLDER = '{link_locale}' 21 | 22 | DEFAULT_LOCALE = 'en' 23 | DEFAULT_WORKER_POOL = 10 24 | JSON_INDENT = 4 25 | -------------------------------------------------------------------------------- /tests/fixtures/email_globale.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy subject 5 | 6 | 7 |

8 |

Dummy content

9 |

10 | Dummy inline 11 |

12 | Alt text 13 |

14 |

15 | Alt text 16 |

17 |

Unsubscribe from KeepSafe updates, click here

18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/src/en/email.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dummy subject 6 | Awesome subject 7 | 8 | [[#C0D9D9]] 9 | 10 | 11 | Dummy content 12 | Awesome content 13 | 14 | Dummy inline 15 | ![Alt text](/path/to/img.jpg) 16 | ![Alt text](http://path.com/to/{link_locale}/img.jpg) 17 | 18 | -------------------------------------------------------------------------------- /email_parser/config.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | Paths = namedtuple('Paths', ['source', 'destination', 'templates', 'images', 'sections']) 4 | 5 | _default_paths = Paths('src', 'target', 'templates_html', 'templates_html/img', 'templates_html/sections') 6 | _default_pattern = '{locale}/{name}.xml' 7 | _default_base_img_path = 'http://app.getkeepsafe.com/emails/img' 8 | _default_rtl = ['ar', 'he'] 9 | _default_lang_mappings = {'pt-BR': 'pt', 'zh-TW-Hant': 'zh-TW'} 10 | 11 | paths = _default_paths 12 | pattern = _default_pattern 13 | base_img_path = _default_base_img_path 14 | rtl_locales = _default_rtl 15 | lang_mappings = _default_lang_mappings 16 | 17 | 18 | def init(*, 19 | _paths=_default_paths, 20 | _pattern=_default_pattern, 21 | _base_img_path=_default_base_img_path, 22 | _rtl_locales=_default_rtl, 23 | _lang_mappings=_default_lang_mappings): 24 | global paths, pattern, base_img_path, rtl_locales, lang_mappings 25 | paths = _default_paths 26 | pattern = _pattern 27 | base_img_path = _base_img_path 28 | rtl_locales = _rtl_locales 29 | lang_mappings = _lang_mappings 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | version = '0.3.2' 5 | 6 | install_requires = [ 7 | 'Markdown < 3', 8 | 'beautifulsoup4 < 5', 9 | 'inlinestyler==0.2.1', 10 | 'pystache < 0.6', 11 | 'parse < 2' 12 | ] 13 | 14 | tests_require = [ 15 | 'nose', 16 | 'flake8==2.5.4', 17 | 'coverage', 18 | ] 19 | 20 | devtools_require = [ 21 | 'twine', 22 | 'build', 23 | ] 24 | 25 | 26 | def read(f): 27 | return open(os.path.join(os.path.dirname(__file__), f)).read().strip() 28 | 29 | setup( 30 | name='ks-email-parser', 31 | version=version, 32 | description=('A command line tool to render HTML and text emails of markdown content.'), 33 | classifiers=[ 34 | 'License :: OSI Approved :: BSD License', 'Intended Audience :: Developers', 'Programming Language :: Python' 35 | ], 36 | author='Keepsafe', 37 | author_email='support@getkeepsafe.com', 38 | url='https://github.com/KeepSafe/ks-email-parser', 39 | license='Apache', 40 | packages=find_packages(), 41 | install_requires=install_requires, 42 | tests_require=tests_require, 43 | extras_require={ 44 | 'tests': tests_require, 45 | 'devtools': devtools_require, 46 | }, 47 | entry_points={'console_scripts': ['ks-email-parser = email_parser.cmd:main']}, 48 | include_package_data=True) 49 | 50 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Some simple testing tasks (sorry, UNIX only). 2 | 3 | PYTHON=venv/bin/python3 4 | PIP=venv/bin/pip 5 | EI=venv/bin/easy_install 6 | NOSE=venv/bin/nosetests 7 | FLAKE=venv/bin/flake8 8 | EMAILS_TEMPLATES_URI=git@github.com:KeepSafe/emails.git 9 | EMAILS_PATH=emails 10 | GUI_BIN=ks-email-parser 11 | FLAGS=--with-coverage --cover-inclusive --cover-erase --cover-package=email_parser --cover-min-percentage=70 12 | PYPICLOUD_HOST=pypicloud.getkeepsafe.local 13 | TWINE=./venv/bin/twine 14 | 15 | 16 | update: 17 | $(PIP) install -U pip 18 | $(PIP) install -U . 19 | 20 | env: 21 | test -d venv || python3 -m venv venv 22 | 23 | dev: env update 24 | $(PIP) install .[tests,devtools] 25 | 26 | install: env update 27 | 28 | publish: 29 | rm -rf dist 30 | $(PYTHON) -m build . 31 | $(TWINE) upload --verbose --sign --username developer --repository-url http://$(PYPICLOUD_HOST)/simple/ dist/*.whl 32 | 33 | 34 | rungui: 35 | test -e $(EMAILS_PATH) && echo Emails templates already cloned || git clone $(EMAILS_TEMPLATES_URI) $(EMAILS_PATH); 36 | $(GUI_BIN) -s $(EMAILS_PATH)/src -d $(EMAILS_PATH)/target -t $(EMAILS_PATH)/templates_html gui 37 | 38 | flake: 39 | $(FLAKE) email_parser tests 40 | 41 | test: flake 42 | $(NOSE) -s $(FLAGS) 43 | 44 | vtest: 45 | $(NOSE) -s -v $(FLAGS) 46 | 47 | testloop: 48 | while sleep 1; do $(NOSE) -s $(FLAGS); done 49 | 50 | cov cover coverage: 51 | $(NOSE) -s --with-cover --cover-html --cover-html-dir ./coverage $(FLAGS) 52 | echo "open file://`pwd`/coverage/index.html" 53 | 54 | clean: 55 | rm -rf `find . -name __pycache__` 56 | rm -f `find . -type f -name '*.py[co]' ` 57 | rm -f `find . -type f -name '*~' ` 58 | rm -f `find . -type f -name '.*~' ` 59 | rm -f `find . -type f -name '@*' ` 60 | rm -f `find . -type f -name '#*#' ` 61 | rm -f `find . -type f -name '*.orig' ` 62 | rm -f `find . -type f -name '*.rej' ` 63 | rm -f .coverage 64 | rm -rf coverage 65 | rm -rf build 66 | rm -rf venv 67 | 68 | 69 | .PHONY: all build env linux run pep test vtest testloop cov clean 70 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 0.2.9 (2016-04-14) 5 | ------------------ 6 | 7 | - Link locale placeholder. 8 | 9 | 0.2.8 (2016-06-20) 10 | ------------------ 11 | 12 | - Global placeholders. 13 | 14 | 0.2.6 (2016-04-09) 15 | ------------------ 16 | 17 | - Warn on extra placeholders. 18 | 19 | 0.2.2 (2015-07-23) 20 | ------------------ 21 | 22 | - Log to stdout by default. 23 | 24 | 0.2.1 (2015-07-23) 25 | ------------------ 26 | 27 | - Validate number of placeholders too. 28 | 29 | 30 | 0.2.0 (2015-07-22) 31 | ------------------ 32 | 33 | - Placeholders validation. 34 | 35 | 36 | 0.1.12 (2015-05-07) 37 | ------------------ 38 | 39 | - Add --version cmd option. 40 | 41 | 42 | 0.1.11 (2015-04-14) 43 | ------------------ 44 | 45 | - Set dir attribute only when possible. 46 | 47 | 0.1.10 (2015-04-14) 48 | ------------------ 49 | 50 | - Set dir attribute for entire content not for each placeholder. 51 | 52 | 0.1.9 (2015-04-06) 53 | ------------------ 54 | 55 | - Use href for links only if available. 56 | 57 | 0.1.8 (2015-04-06) 58 | ------------------ 59 | 60 | - Improve logging for strict mode. 61 | 62 | 0.1.7 (2015-04-06) 63 | ------------------ 64 | 65 | - Improve logging for strict mode. 66 | 67 | 0.1.6 (2015-04-06) 68 | ------------------ 69 | 70 | - Dependencies update. 71 | 72 | 0.1.5 (2015-04-06) 73 | ------------------ 74 | 75 | - Adds default parser options. 76 | 77 | 0.1.4 (2015-04-06) 78 | ------------------ 79 | 80 | - Log filename if parsing fails. 81 | 82 | 0.1.3 (2015-04-06) 83 | ------------------ 84 | 85 | - Fixes missing dependency. 86 | 87 | 0.1.2 (2015-04-06) 88 | ------------------ 89 | 90 | - Fixes requirements parsing. 91 | 92 | 0.1.1 (2015-04-06) 93 | ------------------ 94 | 95 | - Use text and href for links in text files. 96 | 97 | 0.1.0 (2015-04-06) 98 | ------------------ 99 | 100 | - Refactorings. 101 | 102 | 103 | 0.0.1 (2014-09-30) 104 | ------------------ 105 | 106 | - Initial release. 107 | -------------------------------------------------------------------------------- /tests/test_cmd.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import shutil 4 | from unittest import TestCase 5 | 6 | from email_parser import fs, cmd, config 7 | 8 | 9 | def read_fixture(filename): 10 | with open(os.path.join('tests/fixtures', filename)) as fp: 11 | return fp.read() 12 | 13 | 14 | class TestParser(TestCase): 15 | maxDiff = None 16 | 17 | @classmethod 18 | def setUpClass(cls): 19 | cls.root_path = tempfile.mkdtemp() 20 | shutil.copytree(os.path.join('./tests', config.paths.source), os.path.join(cls.root_path, config.paths.source)) 21 | shutil.copytree( 22 | os.path.join('./tests', config.paths.templates), os.path.join(cls.root_path, config.paths.templates)) 23 | cmd.parse_emails(cls.root_path) 24 | 25 | @classmethod 26 | def tearDownClass(cls): 27 | shutil.rmtree(cls.root_path) 28 | 29 | def _run_and_assert(self, actual_filename, expected_filename=None, locale='en'): 30 | expected_filename = expected_filename or actual_filename 31 | expected = read_fixture(expected_filename).strip() 32 | actual = fs.read_file(TestParser.root_path, config.paths.destination, locale, actual_filename).strip() 33 | self.assertEqual(expected, actual) 34 | 35 | def test_subject(self): 36 | self._run_and_assert('email.subject') 37 | 38 | def test_text(self): 39 | self._run_and_assert('email.text') 40 | 41 | def test_html(self): 42 | self._run_and_assert('email.html') 43 | 44 | def test_global_text(self): 45 | self._run_and_assert('email_globale.text') 46 | 47 | def test_global_html(self): 48 | self._run_and_assert('email_globale.html') 49 | 50 | def test_rtl(self): 51 | self._run_and_assert('email.html', 'email.rtl.html', 'ar') 52 | 53 | def test_template_fallback(self): 54 | expected = fs.read_file(TestParser.root_path, config.paths.destination, 'en', 'fallback.html').strip() 55 | actual = fs.read_file(TestParser.root_path, config.paths.destination, 'fr', 'fallback.html').strip() 56 | self.assertEqual(expected, actual) 57 | -------------------------------------------------------------------------------- /email_parser/placeholder.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from functools import lru_cache 3 | import re 4 | import json 5 | import logging 6 | 7 | from . import reader, fs, const 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | def _extract_placeholders(text): 13 | return Counter(m.group(1) for m in re.finditer(r'\{\{(\w+)\}\}', text)) 14 | 15 | 16 | @lru_cache(maxsize=None) 17 | def expected_placeholders_file(root_path): 18 | content = fs.read_file(root_path, const.REPO_SRC_PATH, const.PLACEHOLDERS_FILENAME) 19 | return json.loads(content) 20 | 21 | 22 | def _email_placeholders(root_path, email): 23 | _, contents = reader.read(root_path, email) 24 | content = ''.join(map(lambda c: c.get_content(), contents.values())) 25 | return _extract_placeholders(content) 26 | 27 | 28 | def get_email_validation(root_path, email): 29 | email_placeholders = _email_placeholders(root_path, email) 30 | expected_placeholders = expected_placeholders_file(root_path).get(email.name, {}) 31 | missing_placeholders = set(expected_placeholders) - set(email_placeholders) 32 | extra_placeholders = set(email_placeholders) - set(expected_placeholders) 33 | diff_number = [] 34 | for expected_name, expected_count in expected_placeholders.items(): 35 | email_count = email_placeholders.get(expected_name) 36 | if email_count and expected_count != email_count: 37 | diff_number.append({'placeholder': expected_name, 'expected_count': expected_count, 'count': email_count}) 38 | 39 | valid = not missing_placeholders and not extra_placeholders and not diff_number 40 | if not valid: 41 | errors = { 42 | 'missing': list(missing_placeholders), 43 | 'extra': list(extra_placeholders), 44 | 'diff_number': diff_number 45 | } 46 | else: 47 | errors = None 48 | 49 | return {'valid': valid, 'errors': errors} 50 | 51 | 52 | def generate_config(root_path): 53 | emails = fs.emails(root_path, locale=const.DEFAULT_LOCALE) 54 | placeholders = {email.name: _email_placeholders(root_path, email) for email in emails} 55 | return placeholders 56 | -------------------------------------------------------------------------------- /tests/test_placeholder.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | from collections import Counter 4 | 5 | from email_parser import placeholder, fs 6 | from email_parser.model import * 7 | 8 | 9 | class TestGenerator(TestCase): 10 | def setUp(self): 11 | super().setUp() 12 | 13 | self.patch_fs = patch('email_parser.placeholder.fs') 14 | self.mock_fs = self.patch_fs.start() 15 | self.mock_fs.read_file.return_value = 'test' 16 | 17 | self.patch_reader = patch('email_parser.placeholder.reader') 18 | self.mock_reader = self.patch_reader.start() 19 | 20 | def tearDown(self): 21 | super().tearDown() 22 | self.patch_fs.stop() 23 | self.patch_reader.stop() 24 | 25 | def test_happy_path(self): 26 | self.mock_fs.emails.return_value = iter([Email('test_name', 'en', 'path')]) 27 | self.mock_reader.read.return_value = ('', {'segment': Placeholder('segment', '{{placeholder}}')}) 28 | config = placeholder.generate_config('.') 29 | self.assertEqual(config, {'test_name': Counter({'placeholder': 1})}) 30 | 31 | def test_no_emails(self): 32 | self.mock_fs.emails.return_value = iter([]) 33 | config = placeholder.generate_config('.') 34 | self.assertEqual(config, {}) 35 | 36 | 37 | class TestValidate(TestCase): 38 | def setUp(self): 39 | self.email = fs.Email('test_name', 'en', 'path') 40 | placeholder.expected_placeholders_file.cache_clear() 41 | 42 | self.patch_reader = patch('email_parser.placeholder.reader') 43 | self.mock_reader = self.patch_reader.start() 44 | self.mock_reader.read.return_value = ('', {'segment': Placeholder('segment', '{{test_placeholder}}')}) 45 | 46 | self.patch_config = patch('email_parser.placeholder.expected_placeholders_file') 47 | self.mock_config = self.patch_config.start() 48 | self.mock_config.return_value = {self.email.name: {'test_placeholder': 1}} 49 | 50 | def tearDown(self): 51 | super().tearDown() 52 | self.patch_reader.stop() 53 | 54 | def test_happy_path(self): 55 | actual = placeholder.get_email_validation('.', self.email, ) 56 | expected = {'valid': True, 'errors': None} 57 | self.assertEqual(expected, actual) 58 | 59 | def test_missing_placeholder(self): 60 | self.mock_reader.read.return_value = ('', {'segment': Placeholder('segment', 'content')}) 61 | actual = placeholder.get_email_validation('.', self.email) 62 | expected = {'valid': False, 'errors': {'missing': ['test_placeholder'], 'extra': [], 'diff_number': []}} 63 | self.assertEqual(expected, actual) 64 | 65 | def test_extra_placeholder(self): 66 | self.mock_config.return_value = {self.email.name: {}} 67 | actual = placeholder.get_email_validation('.', self.email) 68 | expected = {'valid': False, 'errors': {'missing': [], 'extra': ['test_placeholder'], 'diff_number': []}} 69 | self.assertEqual(expected, actual) 70 | 71 | def test_diffrent_placeholder_count(self): 72 | self.mock_reader.read.return_value = ('', {'segment': Placeholder('segment', 73 | '{{test_placeholder}}{{test_placeholder}}')}) 74 | actual = placeholder.get_email_validation('.', self.email) 75 | expected = { 76 | 'valid': False, 77 | 'errors': { 78 | 'missing': [], 79 | 'extra': [], 80 | 'diff_number': [{'placeholder': 'test_placeholder', 'count': 2, 'expected_count': 1}] 81 | } 82 | } 83 | self.assertEqual(expected, actual) 84 | -------------------------------------------------------------------------------- /email_parser/markdown_ext.py: -------------------------------------------------------------------------------- 1 | from markdown.inlinepatterns import Pattern, ImagePattern, LinkPattern, LINK_RE, IMAGE_LINK_RE 2 | from markdown.blockprocessors import BlockProcessor 3 | from markdown.extensions import Extension 4 | import re 5 | 6 | from . import const 7 | 8 | 9 | class InlineBlockProcessor(BlockProcessor): 10 | """ 11 | Inlines the content instead of parsing it as markdown. 12 | """ 13 | RE = re.compile(const.INLINE_TEXT_PATTERN) 14 | 15 | def test(self, parent, block): 16 | return bool(self.RE.match(block)) 17 | 18 | def run(self, parent, blocks): 19 | block = blocks.pop(0) 20 | m = self.RE.match(block) 21 | if m: 22 | text = m.group(1) 23 | parent.text = text 24 | 25 | 26 | class BaseUrlImagePattern(Pattern): 27 | """ 28 | Adds base url to images which have relative path. 29 | """ 30 | 31 | url_pattern = re.compile( 32 | r'^(?:http|ftp)s?://' # http:// or https:// 33 | r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... 34 | r'localhost|' # localhost... 35 | r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip 36 | r'(?::\d+)?' # optional port 37 | r'(?:/?|[/?]\S+)$', 38 | re.IGNORECASE) 39 | 40 | def __init__(self, images_dir, *args): 41 | super().__init__(*args) 42 | if images_dir: 43 | self.images_dir = images_dir.strip('/') 44 | else: 45 | self.images_dir = '' 46 | self.image_pattern = ImagePattern(*args) 47 | 48 | def _is_url(self, text): 49 | url = text.strip().strip('/').split(' ')[0] 50 | return self.url_pattern.match(url) 51 | 52 | def handleMatch(self, m): 53 | if self._is_url(m.group(10)): 54 | image = m.string 55 | else: 56 | image = const.IMAGE_PATTERN.format(m.group(2), self.images_dir, m.group(10).strip('/')) 57 | pattern = re.compile("^(.*?)%s(.*?)$" % self.image_pattern.pattern, re.DOTALL | re.UNICODE) 58 | match = re.match(pattern, ' ' + image + ' ') 59 | el = self.image_pattern.handleMatch(match) 60 | # each markdown image should have default style 61 | el.set('style', self.unescape('max-width: 100%;')) 62 | return el 63 | 64 | 65 | class NoTrackingLinkPattern(LinkPattern): 66 | def __init__(self, *args): 67 | super().__init__(*args) 68 | 69 | def handleMatch(self, m): 70 | el = super().handleMatch(m) 71 | if el.get('href') and el.get('href').startswith('!'): 72 | el.set('href', el.get('href')[1:]) 73 | el.set('clicktracking', 'off') 74 | return el 75 | 76 | 77 | class InlineTextExtension(Extension): 78 | def extendMarkdown(self, md, md_globals): 79 | md.parser.blockprocessors.add('inline_text', InlineBlockProcessor(md.parser), '\n\t\t$img\n\t""") 103 | img = string.Template("""""") 104 | mapping = dict(self._opt_attr) 105 | optional = "" 106 | div_style = "vertical-align: middle;text-align: center;" 107 | constraints = "" 108 | if self.alt: 109 | optional += " alt=\"{}\"".format(self.alt) 110 | for style_tag in ['max-width', 'max-height']: 111 | if style_tag in mapping: 112 | constraints += "{}: {};".format(style_tag, mapping[style_tag]) 113 | mapping.update({ 114 | 'style': div_style + constraints, 115 | 'id': self.id, 116 | 'optional': optional, 117 | 'src': self.src, 118 | 'img_style': constraints 119 | }) 120 | mapping['img'] = img.substitute(mapping) 121 | final_wrapper = wrapper.substitute(mapping) 122 | return final_wrapper 123 | 124 | def set_attr(self, attr): 125 | self._opt_attr = attr 126 | 127 | 128 | class MissingPatternParamError(Exception): 129 | pass 130 | 131 | 132 | class MissingSubjectError(Exception): 133 | pass 134 | 135 | 136 | class MissingTemplatePlaceholderError(Exception): 137 | pass 138 | 139 | 140 | class RenderingError(Exception): 141 | pass 142 | -------------------------------------------------------------------------------- /email_parser/cmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handles command line and calls the email parser with corrent options. 3 | """ 4 | 5 | import argparse 6 | import logging 7 | import sys 8 | import os 9 | import shutil 10 | import asyncio 11 | import concurrent.futures 12 | from itertools import islice 13 | from functools import reduce 14 | from multiprocessing import Manager 15 | 16 | from . import const, Parser, config, fs 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class ProgressConsoleHandler(logging.StreamHandler): 22 | store_msg_loglevels = (logging.ERROR, logging.WARN) 23 | 24 | on_same_line = False 25 | flush_errors = False 26 | 27 | def __init__(self, err_queue, warn_queue, *args, **kwargs): 28 | self.err_msgs_queue = err_queue 29 | self.warn_msgs_queue = warn_queue 30 | super(ProgressConsoleHandler, self).__init__(*args, **kwargs) 31 | 32 | def _store_msg(self, msg, loglevel): 33 | if loglevel == logging.ERROR: 34 | self.err_msgs_queue.put(msg) 35 | if loglevel == logging.WARN: 36 | self.warn_msgs_queue.put(msg) 37 | 38 | def error_msgs(self): 39 | while not self.err_msgs_queue.empty(): 40 | yield self.err_msgs_queue.get() 41 | 42 | def warning_msgs(self): 43 | while not self.warn_msgs_queue.empty(): 44 | yield self.warn_msgs_queue.get() 45 | 46 | def _print_msg(self, stream, msg, record): 47 | same_line = hasattr(record, 'same_line') 48 | if self.on_same_line and not same_line: 49 | stream.write(self.terminator) 50 | stream.write(msg) 51 | if same_line: 52 | self.on_same_line = True 53 | else: 54 | stream.write(self.terminator) 55 | self.on_same_line = False 56 | self.flush() 57 | 58 | def _flush_store(self, stream, msgs, header): 59 | stream.write(self.terminator) 60 | stream.write(header) 61 | stream.write(self.terminator) 62 | for idx, msg in enumerate(msgs): 63 | stream.write('%s. %s' % (idx + 1, msg)) 64 | stream.write(self.terminator) 65 | 66 | def _flush_errors(self, stream): 67 | if not self.err_msgs_queue.empty(): 68 | self._flush_store(stream, self.error_msgs(), 'ERRORS:') 69 | if not self.warn_msgs_queue.empty(): 70 | self._flush_store(stream, self.warning_msgs(), 'WARNINGS:') 71 | 72 | def _write_msg(self, stream, msg, record): 73 | flush_errors = hasattr(record, 'flush_errors') 74 | if flush_errors: 75 | self._flush_errors(stream) 76 | self._print_msg(stream, msg, record) 77 | 78 | def emit(self, record): 79 | try: 80 | msg = self.format(record) 81 | stream = self.stream 82 | if record.levelno in self.store_msg_loglevels: 83 | self._store_msg(msg, record.levelno) 84 | else: 85 | self._write_msg(stream, msg, record) 86 | except (KeyboardInterrupt, SystemExit): 87 | raise 88 | except Exception: 89 | self.handleError(record) 90 | 91 | 92 | def read_args(argsargs=argparse.ArgumentParser): 93 | logger.debug('reading arguments list') 94 | args = argsargs(epilog='Brought to you by KeepSafe - www.getkeepsafe.com') 95 | 96 | args.add_argument('-i', '--images', help='Images base directory') 97 | args.add_argument('-vv', '--verbose', help='Generate emails despite errors', action='store_true') 98 | args.add_argument('-v', '--version', help='Show version', action='store_true') 99 | 100 | subparsers = args.add_subparsers(help='Parser additional commands', dest='command') 101 | 102 | config_parser = subparsers.add_parser('config') 103 | config_parser.add_argument('config_name', help='Name of config to generate. Available: `placeholders`') 104 | 105 | return args.parse_args() 106 | 107 | 108 | def _parse_and_save(email, parser): 109 | result = parser.render_email(email) 110 | if result: 111 | subject, text, html = result 112 | fs.save_parsed_email(parser.root_path, email, subject, text, html) 113 | return True 114 | else: 115 | return False 116 | 117 | 118 | def _parse_emails_batch(emails, parser): 119 | results = [] 120 | for email in emails: 121 | try: 122 | results.append(_parse_and_save(email, parser)) 123 | except Exception as ex: 124 | logger.exception('Cannot _parse_and_save email %s', email, exc_info=ex) 125 | 126 | result = reduce(lambda acc, res: acc and res, results) 127 | return result 128 | 129 | 130 | def _parse_emails(loop, root_path): 131 | shutil.rmtree(os.path.join(root_path, config.paths.destination), ignore_errors=True) 132 | emails = fs.emails(root_path) 133 | executor = concurrent.futures.ProcessPoolExecutor(max_workers=const.DEFAULT_WORKER_POOL) 134 | tasks = [] 135 | parser = Parser(root_path) 136 | 137 | emails_batch = list(islice(emails, const.DEFAULT_WORKER_POOL)) 138 | while emails_batch: 139 | task = loop.run_in_executor(executor, _parse_emails_batch, emails_batch, parser) 140 | tasks.append(task) 141 | emails_batch = list(islice(emails, const.DEFAULT_WORKER_POOL)) 142 | results = yield from asyncio.gather(*tasks) 143 | result = reduce(lambda acc, result: True if acc and result else False, results) 144 | return result 145 | 146 | 147 | def parse_emails(root_path): 148 | loop = init_loop() 149 | result = loop.run_until_complete(_parse_emails(loop, root_path)) 150 | return result 151 | 152 | 153 | def print_version(): 154 | import pkg_resources 155 | version = pkg_resources.require('ks-email-parser')[0].version 156 | print(version) 157 | return True 158 | 159 | 160 | def generate_config(root_path): 161 | logger.info('generating config for placeholders') 162 | Parser(root_path).refresh_email_placeholders_config() 163 | return True 164 | 165 | 166 | def execute_command(args): 167 | if args.command == 'config' and args.config_name == 'placeholders': 168 | return generate_config(args) 169 | return False 170 | 171 | 172 | def init_log(verbose): 173 | log_level = logging.DEBUG if verbose else logging.INFO 174 | error_msgs_queue = Manager().Queue() 175 | warning_msgs_queue = Manager().Queue() 176 | handler = ProgressConsoleHandler(error_msgs_queue, warning_msgs_queue, stream=sys.stdout) 177 | logger.setLevel(log_level) 178 | logger.addHandler(handler) 179 | 180 | 181 | def init_loop(): 182 | loop = asyncio.get_event_loop() 183 | loop.set_debug(False) 184 | return loop 185 | 186 | 187 | def main(): 188 | root_path = os.getcwd() 189 | args = read_args() 190 | init_log(args.verbose) 191 | if args.images: 192 | config.base_img_path = args.images 193 | if args.version: 194 | result = print_version() 195 | elif args.command: 196 | result = execute_command(args) 197 | else: 198 | result = parse_emails(root_path) 199 | logger.info('\nAll done', extra={'flush_errors': True}) 200 | sys.exit(0) if result else sys.exit(1) 201 | 202 | 203 | if __name__ == '__main__': 204 | main() 205 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ks-email-parser [![Build Status](https://travis-ci.org/KeepSafe/ks-email-parser.svg?branch=master)](https://travis-ci.org/KeepSafe/ks-email-parser) 2 | 3 | A command line tool name `ks-email-parser` to render HTML and text emails of markdown content. 4 | 5 | ## Goal 6 | The goal is to store emails in a unified format that is easy to translate and to generate HTML and text emails off. It should be easy for translators to maintain content formatting accords different languages. 7 | 8 | ## Requirements 9 | 10 | 1. Python 3.+ 11 | 2. libxml - on OSX install with `xcode-select --install` 12 | 13 | ## Installation 14 | 15 | `make install` 16 | 17 | ## Usage 18 | 19 | `ks-email-parser` in root folder to generate all emails. 20 | 21 | 22 | ### Options 23 | 24 | Run `ks-email-parser --help` to see available options. 25 | 26 | 27 | ## Format 28 | Emails are defined as plain text or markdown for simple translation. The folder structure makes it easy to plug into an existing translation tool. 29 | The content of each email is stored in a XML file that contains all content, the subject and the assosiated HTML template. 30 | 31 | ### Syntax 32 | 33 | ``` 34 | 35 | 36 | text_string 40 | 41 | ``` 42 | 43 | #### Inline text 44 | 45 | By default any text simple text you put in a `` tag will be wrapped in a `

` tag, so `simple text` would become `

simple text

`. 46 | This is standard markdown behaviour. In case you want to get raw text output, for example if you want to use it in a link tag, 47 | wrap the entire block in `[[text]]`, for example `[[www.google.pl]]` would become `www.google.pl`. 48 | This is true only for entire blocks of text (paragraphs separated by blanck lines), `[[www.google.pl]] hello` 49 | would be rendered as `[[www.google.pl]] hello` 50 | 51 | #### Link locale 52 | 53 | `{link_locale}` is special placeholder, which is resolved by mapping located in `src/link_locale_mappings.json` during rendering. 54 | If email locale couldn't be mapped to link locale, it's `en` by default. 55 | 56 | ``` 57 | { 58 | "email locale": "link locale", 59 | "zh-TW-Hant": "zh" 60 | } 61 | ``` 62 | If email locale will be `zh-TW-Hant`, then `[something](https://getkeepsafe.com/?locale={link_locale})` will be rendered as `[something](https://getkeepsafe.com/?locale=zh)`. 63 | 64 | 65 | #### Base url for images 66 | 67 | The parser will automatically add base_url to any image tag in markdown, so `![Alt text](/path/to/img.jpg)` and base url `base_url` 68 | will produce `Alt text` 69 | 70 | #### No tracking for links in Sendgrid 71 | 72 | Click tracking interferes with our deep links by augmenting the link. To disable click tracking for specific links add `!` in front of a link like so `[Alt text](!keepsafe://)` 73 | 74 | ### Elements 75 | 76 | #### `resource` 77 | Resource attributes: 78 | 79 | - **template** - the name of the corresponding HTML template to render 80 | - **style** - (optional) comma separated value of CSS to be used for HTML templates. Those will be applied in order of the list. 81 | 82 | #### `string` 83 | Content formatting 84 | 85 | - Plain text 86 | - Markdown wrapped in `![CDATA[` 87 | 88 | String attributes: 89 | 90 | - **name** - Name of the matching place holder `[name_value]` in the HTML template 91 | - **order** - (optional) in case of multiple string elements, they get rendered in the right order into text emails. 92 | 93 | #### Example 94 | 95 | ``` 96 | 97 | 98 | Verify your email address with KeepSafe. 99 | 107 | 108 | ``` 109 | 110 | ## Templates 111 | 112 | HTML templates use [Mustache](http://mustache.github.io/) to parse the template. You can use `{{name}}` inside a template and it will be replace by `name` string element from the email XML or you can use `{{global_name}}` and it will be replaced by `name` string elemenet from `/global.xml` (this file has exactly the same structure as usual email XML and behaviour, except it won't be parsed). You can find example of the templates in `templates_html` folder in this repo. 113 | 114 | 115 | ## Folder structure 116 | 117 | ``` 118 | src/ 119 | en/ 120 | email_template.xml 121 | global.xml 122 | target/ 123 | templates_html/ 124 | html_template.html 125 | html_template.css 126 | ``` 127 | 128 | - `src/` - all email content by local. e.g. `src/en/` for english content 129 | - `target/` - Output folder generated. Contains the same local subfolders as `src/`. Each email generates 3 files with the self explained file extensions `.txt`, `.html` and `.subject` 130 | - `templates_html/` - all HTML templates and CSS styles. A HTML template can have a corresponding CSS file with the same name. 131 | 132 | This structure is configurable. By changing `source`, `destination`, `templates` and `pattern` you can use a structure you like. The `pattern` parameter is especially useful as it controls directory layout and email names. the default is `{locale}/{name}.xml` but you can use `{name}.{locale}.xml` if you don't want to have nested directories. Keep in mind both `name` and `locale` are required in the pattern. 133 | 134 | ## Rendering 135 | *ks-email-parser* renders email content into HTML in 2 steps. 136 | 137 | 1. Render markdown files into simple HTML 138 | 2. Inserting CSS style definitions from `html_template.css` inline into the HTML. The goal is to support email clients that don't support inline CSS formatting. 139 | 140 | ### Strict mode 141 | 142 | You can use `--strict` option to make sure all placeholders are filled. If there are leftover placeholders the parsing will fail with an error. 143 | 144 | ### isText 145 | 146 | In case you want to put some non-text values in emails, like colors, you can use placeholders which will be ignored in text emails: 147 | 148 | `[[#C0D9D9]]` 149 | 150 | The only valid false value for isText is `false`, everything else counts as true including omitting the attribute. 151 | 152 | ## Placeholders validation 153 | 154 | To make sure the placeholders are consistent between languages and every language has all needed placeholders you can create configuration file to hold needed placeholders. 155 | 156 | ### Config file 157 | 158 | The file is a mapping of name to required placeholders and the number of times they appear. It's a json file with structure as below: 159 | 160 | ``` 161 | { 162 | "email name" : {"placeholder1":1, "placeholder2":2} 163 | } 164 | ``` 165 | 166 | You can generate the file in the provided source directory from existing emails with 167 | 168 | ``` 169 | $ ks-email-parser config placeholders 170 | ``` 171 | 172 | It will go through your email and extract placeholders. 173 | 174 | ### Validation 175 | 176 | If the config file is present in the source directory each email will be validated for having placeholders specified in the file. The parsing will fail with an error if any email is missing one of required placeholders. 177 | 178 | ## 3rd party support 179 | Some 3rd party services have custom formats to represent emails in multiple languages. This is a list of supported providers. 180 | 181 | ### Customer.io 182 | Customer.io defines their own multi language email format. More: [http://customer.io/docs/localization-i18n.html](http://customer.io/docs/localization-i18n.html) 183 | 184 | #### Usage 185 | `ks-email-parser customerio [email_name]` 186 | Generates a valid customer.io multi language email format into the `target/` folder. 187 | -------------------------------------------------------------------------------- /email_parser/fs.py: -------------------------------------------------------------------------------- 1 | """ 2 | All filesystem interaction. 3 | """ 4 | 5 | import logging 6 | import os 7 | import parse 8 | from pathlib import Path 9 | from string import Formatter 10 | 11 | from . import const, config 12 | from .model import * 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def _parse_params(pattern): 18 | params = [p for p in map(lambda e: e[1], Formatter().parse(pattern)) if p] 19 | if 'name' not in params: 20 | raise MissingPatternParamError( 21 | '{{name}} is a required parameter in the pattern but it is not present in {}'.format(pattern)) 22 | if 'locale' not in params: 23 | raise MissingPatternParamError( 24 | '{{locale}} is a required parameter in the pattern but it is not present in {}'.format(pattern)) 25 | return params 26 | 27 | 28 | def _has_correct_ext(path, pattern): 29 | return os.path.splitext(str(path))[1] == os.path.splitext(pattern)[1] 30 | 31 | 32 | # TODO extract globals 33 | def _emails(root_path, pattern, params): 34 | source_path = os.path.join(root_path, config.paths.source) 35 | wildcard_params = {k: '*' for k in params} 36 | wildcard_pattern = pattern.format(**wildcard_params) 37 | parser = parse.compile(pattern) 38 | glob_path = Path(source_path).glob(wildcard_pattern) 39 | for path in sorted(glob_path, key=lambda path: str(path)): 40 | if not path.is_dir() and _has_correct_ext(path, pattern): 41 | str_path = str(path.relative_to(source_path)) 42 | result = parser.parse(str_path) 43 | if result: # HACK: result can be empty when pattern doesn't contain any placeholder 44 | result.named['path'] = str(path.resolve()) 45 | if not str_path.endswith(const.GLOBALS_EMAIL_NAME + const.SOURCE_EXTENSION): 46 | logger.debug('loading email %s', result.named['path']) 47 | yield result 48 | 49 | 50 | def get_email_filepath(email_name, locale): 51 | pattern = config.pattern.replace('{name}', email_name) 52 | pattern = pattern.replace('{locale}', locale) 53 | filepath = os.path.join(config.paths.source, pattern) 54 | return filepath 55 | 56 | 57 | def get_template_filepath(root_path, template_name, template_type): 58 | return Path(root_path, config.paths.templates, template_type, template_name) 59 | 60 | 61 | def get_template_resources_filepaths(root_path, template): 62 | """ 63 | :return: list of file paths of resources (css & html) related with given email 64 | """ 65 | paths = [Path(root_path, config.paths.templates, f) or ' ' for f in template.styles_names] 66 | paths.append(get_template_filepath(root_path, template.name, template.type)) 67 | return paths 68 | 69 | 70 | def emails(root_path, email_name=None, locale=None): 71 | """ 72 | Resolves a pattern to a collection of emails. 73 | 74 | :param src_dir: base dir for the search 75 | :param pattern: search pattern 76 | :exclusive_path: single email path, glob path for emails subset or None to not affect emails set 77 | 78 | :returns: generator for the emails matching the pattern 79 | """ 80 | params = _parse_params(config.pattern) 81 | pattern = config.pattern 82 | if email_name: 83 | pattern = pattern.replace('{name}', email_name) 84 | if locale: 85 | pattern = pattern.replace('{locale}', locale) 86 | for result in _emails(root_path, pattern, params): 87 | if email_name: 88 | result.named['name'] = email_name 89 | if locale: 90 | result.named['locale'] = locale 91 | yield Email(**result.named) 92 | 93 | 94 | def email(root_path, email_name, locale): 95 | """ 96 | Gets an email by name and locale 97 | 98 | :param src_dir: base dir for the search 99 | :param pattern: search pattern 100 | :param email_name: email name 101 | :param locale: locale name or None for all locales 102 | 103 | :returns: generator for the emails with email_name 104 | """ 105 | params = _parse_params(config.pattern) 106 | pattern = config.pattern.replace('{name}', email_name) 107 | pattern = pattern.replace('{locale}', locale) 108 | for result in _emails(root_path, pattern, params): 109 | result.named['name'] = email_name 110 | result.named['locale'] = locale 111 | return Email(**result.named) 112 | return None 113 | 114 | 115 | def global_email(root_path, locale): 116 | path = os.path.join(root_path, config.paths.source, locale, const.GLOBALS_EMAIL_NAME + const.SOURCE_EXTENSION) 117 | return Email(const.GLOBALS_EMAIL_NAME, locale, path) 118 | 119 | 120 | def read_file(*path_parts): 121 | """ 122 | Helper for reading files 123 | """ 124 | path = os.path.join(*path_parts) 125 | logger.debug('reading file from %s', path) 126 | with open(path) as fp: 127 | return fp.read() 128 | 129 | 130 | def save_file(content, *path_parts): 131 | """ 132 | Helper for saving files 133 | """ 134 | path = os.path.join(*path_parts) 135 | logger.debug('saving file to %s', path) 136 | with open(path, 'w') as fp: 137 | return fp.write(content) 138 | 139 | 140 | def delete_file(*path_parts): 141 | """ 142 | Helper for deleting files 143 | """ 144 | path = os.path.join(*path_parts) 145 | logger.debug('deleting file to %s', path) 146 | os.remove(path) 147 | 148 | 149 | def save_email(root_path, content, email_name, locale): 150 | pattern = config.pattern.replace('{locale}', locale) 151 | pattern = pattern.replace('{name}', email_name) 152 | path = os.path.join(root_path, config.paths.source, pattern) 153 | save_file(content, path) 154 | return path 155 | 156 | 157 | def save_template(root_path, template_filename, template_type, template_content): 158 | path = os.path.join(root_path, config.paths.templates, template_type.value, template_filename) 159 | written = save_file(template_content, path) 160 | return path, written 161 | 162 | 163 | def save_parsed_email(root_path, email, subject, text, html): 164 | """ 165 | Saves an email. The locale and name are taken from email tuple. 166 | 167 | :param email: Email tuple 168 | :param subject: email's subject 169 | :param text: email's body as text 170 | :param html: email's body as html 171 | :param dest_dir: root destination directory 172 | """ 173 | locale = email.locale or const.DEFAULT_LOCALE 174 | folder = os.path.join(root_path, config.paths.destination, locale) 175 | os.makedirs(folder, exist_ok=True) 176 | save_file(subject, folder, email.name + const.SUBJECT_EXTENSION) 177 | save_file(text, folder, email.name + const.TEXT_EXTENSION) 178 | save_file(html, folder, email.name + const.HTML_EXTENSION) 179 | 180 | 181 | def resources(root_path): 182 | """ 183 | TODO: separate styles and templates 184 | Returns a tuple of lists: html templates list and css styles list 185 | :param root_path: 186 | :return: 187 | """ 188 | templates = {} 189 | styles = [] 190 | templates_path = os.path.join(root_path, config.paths.templates) 191 | css_glob = Path(templates_path).glob('*' + const.CSS_EXTENSION) 192 | css_files = sorted(css_glob, key=lambda p: str(p)) 193 | styles.extend(map(lambda p: p.name, css_files)) 194 | html_glob = Path(templates_path).glob('**/*' + const.HTML_EXTENSION) 195 | html_files = sorted(html_glob, key=lambda p: str(p)) 196 | for html_file in html_files: 197 | parent = html_file.relative_to(templates_path).parent 198 | try: 199 | template_type = EmailType(str(parent)).value 200 | templates_list_by_type = templates.setdefault(template_type, []) 201 | templates_list_by_type.append(html_file.name) 202 | except ValueError: 203 | continue 204 | return templates, styles 205 | 206 | 207 | def get_html_sections_map(root_path): 208 | html_sections = {} 209 | html_sections_path = os.path.join(root_path, config.paths.sections) 210 | html_sections_glob = Path(html_sections_path).glob('*' + const.HTML_EXTENSION) 211 | html_sections_files = sorted(html_sections_glob, key=lambda p: str(p)) 212 | for html_sections_path in html_sections_files: 213 | html_sections[html_sections_path.name] = read_file(*html_sections_path.parts) 214 | return html_sections 215 | -------------------------------------------------------------------------------- /email_parser/renderer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Different ways of rendering emails. 3 | """ 4 | 5 | import logging 6 | import re 7 | import xml.etree.ElementTree as ET 8 | 9 | import bs4 10 | import inlinestyler.utils as inline_styler 11 | import markdown 12 | import pystache 13 | 14 | from . import markdown_ext, const, utils, config 15 | from .model import * 16 | from .reader import parse_placeholder 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def _md_to_html(text, base_url=None): 22 | extensions = [markdown_ext.inline_text(), markdown_ext.no_tracking()] 23 | if base_url: 24 | extensions.append(markdown_ext.base_url(base_url)) 25 | return markdown.markdown(text, extensions=extensions) 26 | 27 | 28 | def _split_subject(placeholders): 29 | return (placeholders.get(const.SUBJECT_PLACEHOLDER), 30 | dict((k, v) for k, v in placeholders.items() if k != const.SUBJECT_PLACEHOLDER)) 31 | 32 | 33 | def _transform_extended_tags(content): 34 | regex = r"{{(.*):(.*):(.*)}}" 35 | return re.sub(regex, lambda match: '{{%s}}' % match.group(2), content) 36 | 37 | 38 | class HtmlRenderer(object): 39 | """ 40 | Renders email' body as html. 41 | """ 42 | 43 | def __init__(self, template, email_locale): 44 | self.template = template 45 | self.locale = utils.normalize_locale(email_locale) 46 | 47 | def _inline_css(self, html, css): 48 | # an empty style will cause an error in inline_styler so we use a space instead 49 | css = css or ' ' 50 | html_with_css = inline_styler.inline_css(css + html) 51 | 52 | # inline_styler will return a complete html filling missing html and body tags which we don't want 53 | if html.startswith('<'): 54 | body = ET.fromstring(html_with_css).find('.//body') 55 | body = ''.join(ET.tostring(e, encoding='unicode') for e in body) 56 | else: 57 | body = ET.fromstring(html_with_css).find('.//body/p') 58 | if body is None: 59 | raise ValueError() 60 | body = body.text 61 | 62 | return body.strip() 63 | 64 | def _wrap_with_text_direction(self, html): 65 | if self.locale in config.rtl_locales: 66 | soup = bs4.BeautifulSoup(html, 'html.parser') 67 | for element in soup.contents: 68 | try: 69 | element['dir'] = 'rtl' 70 | break 71 | except TypeError: 72 | continue 73 | return soup.prettify() 74 | else: 75 | return html 76 | 77 | def _wrap_with_highlight(self, html, highlight): 78 | attr_id = highlight.get('id', '') 79 | attr_style = highlight.get('style', '') 80 | 81 | soup = bs4.BeautifulSoup(html, 'html.parser') 82 | tag = soup.new_tag('div', id=attr_id, style=attr_style) 83 | tag.insert(0, soup) 84 | return tag.prettify() 85 | 86 | def _render_placeholder(self, placeholder, variant=None, highlight=None): 87 | content = placeholder.get_content(variant) 88 | if not content.strip(): 89 | return content 90 | content = content.replace(const.LOCALE_PLACEHOLDER, self.locale) 91 | if placeholder.type == PlaceholderType.raw: 92 | return content 93 | else: 94 | html = _md_to_html(content, config.base_img_path) 95 | html = self._inline_css(html, self.template.styles) 96 | if highlight and highlight.get('placeholder') == placeholder.name and highlight.get('variant') == variant: 97 | html = self._wrap_with_highlight(html, highlight) 98 | return html 99 | 100 | def _concat_parts(self, subject, parts, variant): 101 | subject = subject.get_content(variant) if subject is not None else '' 102 | placeholders = dict(parts.items() | {'subject': subject, 'base_url': config.base_img_path}.items()) 103 | try: 104 | # pystache escapes html by default, we pass escape option to disable this 105 | renderer = pystache.Renderer(escape=lambda u: u, missing_tags='strict') 106 | # since pystache tags parsing cant be easily extended: transform all tags extended with types to names only 107 | content = _transform_extended_tags(self.template.content) 108 | return renderer.render(content, placeholders) 109 | except pystache.context.KeyNotFoundError as e: 110 | message = 'template %s for locale %s has missing placeholders: %s' % (self.template.name, self.locale, e) 111 | raise MissingTemplatePlaceholderError(message) from e 112 | 113 | def render(self, placeholders, variant=None, highlight=None): 114 | subject, contents = _split_subject(placeholders) 115 | parts = {k: self._render_placeholder(v, variant, highlight) for k, v in contents.items()} 116 | html = self._concat_parts(subject, parts, variant) 117 | html = self._wrap_with_text_direction(html) 118 | return html 119 | 120 | 121 | class TextRenderer(object): 122 | """ 123 | Renders email's body as text. 124 | """ 125 | 126 | def __init__(self, template, email_locale): 127 | # self.shortener = link_shortener.shortener(settings.shortener) 128 | self.template = template 129 | self.locale = utils.normalize_locale(email_locale) 130 | 131 | def _html_to_text(self, html): 132 | soup = bs4.BeautifulSoup(html, const.HTML_PARSER) 133 | 134 | # replace the value in with the href because soup.get_text() takes the value inside instead or href 135 | anchors = soup.find_all('a') 136 | for anchor in anchors: 137 | text = anchor.string or '' 138 | href = anchor.get('href') or text 139 | # href = self.shortener.shorten(href) 140 | if href != text: 141 | anchor.replace_with('{} ({})'.format(text, href)) 142 | elif href: 143 | anchor.replace_with(href) 144 | 145 | # add prefix to lists, it wont be added automatically 146 | unordered_lists = soup('ul') 147 | for unordered_list in unordered_lists: 148 | for element in unordered_list('li'): 149 | if element.string: 150 | element.replace_with('- ' + element.string) 151 | ordered_lists = soup('ol') 152 | for ordered_list in ordered_lists: 153 | for idx, element in enumerate(ordered_list('li')): 154 | element.replace_with('%s. %s' % (idx + 1, element.string)) 155 | 156 | return soup.get_text() 157 | 158 | def _md_to_text(self, text, base_url=None): 159 | html = _md_to_html(text, base_url) 160 | return self._html_to_text(html) 161 | 162 | def render(self, placeholders, variant=None): 163 | _, contents = _split_subject(placeholders) 164 | parts = [ 165 | self._md_to_text(contents[p].get_content(variant).replace(const.LOCALE_PLACEHOLDER, self.locale)) 166 | for p in self.template.placeholders if p in contents if contents[p].type != PlaceholderType.attribute] 167 | return const.TEXT_EMAIL_PLACEHOLDER_SEPARATOR.join(v for v in filter(bool, parts)) 168 | 169 | 170 | class SubjectRenderer(object): 171 | """ 172 | Renders email's subject as text. 173 | """ 174 | 175 | def render(self, placeholders, variant=None): 176 | subject, _ = _split_subject(placeholders) 177 | if subject is None: 178 | raise MissingSubjectError('Subject is required for every email') 179 | return subject.get_content(variant) 180 | 181 | 182 | def render(email_locale, template, placeholders, variant=None, highlight=None): 183 | subject_renderer = SubjectRenderer() 184 | subject = subject_renderer.render(placeholders, variant) 185 | 186 | text_renderer = TextRenderer(template, email_locale) 187 | text = text_renderer.render(placeholders, variant) 188 | 189 | html_renderer = HtmlRenderer(template, email_locale) 190 | try: 191 | html = html_renderer.render(placeholders, variant, highlight) 192 | except MissingTemplatePlaceholderError as e: 193 | message = 'failed to generate html content for locale: {} with message: {}'.format(email_locale, e) 194 | raise RenderingError(message) from e 195 | 196 | return subject, text, html 197 | -------------------------------------------------------------------------------- /tests/test_reader.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | from lxml import etree 6 | 7 | from email_parser import reader 8 | from email_parser.model import * 9 | 10 | 11 | def read_fixture(filename, decoder=None): 12 | with open(os.path.join('tests/fixtures', filename)) as fp: 13 | content = fp.read() 14 | if decoder: 15 | return decoder(content) 16 | return content 17 | 18 | 19 | class TestReader(TestCase): 20 | def setUp(self): 21 | super().setUp() 22 | self.maxDiff = None 23 | self.email = Email(name='dummy', locale='dummy', path='dummy') 24 | self.email_content = """ 25 | 26 | dummy subject 27 | dummy content 28 | 29 | hello 30 | bye 31 | 32 | 33 | """ 34 | self.globals_xml = etree.fromstring(""" 35 | 36 | dummy global 37 | asc 38 | 39 | """) 40 | self.template_str = '{{content}}{{global_content}}' 41 | 42 | self.patch_parse = patch('email_parser.reader.etree.parse') 43 | self.mock_parse = self.patch_parse.start() 44 | self.mock_parse.return_value = etree.ElementTree(self.globals_xml).getroot() 45 | 46 | self.patch_fs = patch('email_parser.reader.fs') 47 | self.mock_fs = self.patch_fs.start() 48 | self.mock_fs.read_file.return_value = 'test' 49 | 50 | def tearDown(self): 51 | super().tearDown() 52 | self.patch_fs.stop() 53 | self.patch_parse.stop() 54 | 55 | def test_template(self): 56 | self.mock_fs.read_file.side_effect = iter([self.email_content, self.template_str, 'test']) 57 | expected_placeholders = { 58 | 'content': MetaPlaceholder('content'), 59 | 'global_content': MetaPlaceholder('global_content') 60 | } 61 | expected_template = Template('dummy_template.html', ['dummy_template.css'], '', 62 | self.template_str, expected_placeholders, EmailType.transactional.value) 63 | template, _ = reader.read('.', self.email) 64 | self.assertEqual(expected_template, template) 65 | 66 | def test_get_template_parts_no_email_type(self): 67 | self.mock_fs.read_file.side_effect = iter([self.template_str]) 68 | content, placeholders = reader.get_template_parts('root-path', 'basic_template.html', None) 69 | self.assertEqual(content, self.template_str) 70 | 71 | def test_placeholders(self): 72 | self.mock_fs.read_file.side_effect = iter([self.email_content, self.template_str, 'test']) 73 | expected = { 74 | 'subject': Placeholder('subject', 'dummy subject'), 75 | 'content': Placeholder('content', 'dummy content'), 76 | 'global_content': Placeholder('global_content', 'dummy global', True), 77 | 'block': Placeholder('block', 'hello', False, PlaceholderType.text, {'B': 'bye'}) 78 | } 79 | _, placeholders = reader.read('.', self.email) 80 | self.assertEqual(expected.keys(), placeholders.keys()) 81 | 82 | def test_template_with_multiple_styles(self): 83 | email_content = """ 84 | 86 | 87 | 88 | 89 | """ 90 | self.mock_fs.read_file.return_value = 'test' 91 | self.mock_fs.global_email().path = 'test' 92 | template, _ = reader.read_from_content('.', email_content, 'en') 93 | self.assertEqual('', template.styles) 94 | 95 | def test_template_with_bitmap_placeholder(self): 96 | email_content = """ 97 | 98 | 99 | XXX 100 | YYY 101 | 102 | 103 | """ 104 | template_str = '{{image:MYIMAGE:max_width=160}}' 105 | self.mock_fs.read_file.side_effect = iter([email_content, template_str, 'test']) 106 | template, placeholders = reader.read_from_content('.', email_content, 'en') 107 | self.assertTrue('MY_IMAGE' in placeholders.keys()) 108 | 109 | def test_read_by_content(self): 110 | self.mock_fs.read_file.return_value = self.template_str 111 | template, _ = reader.read_from_content('.', self.email_content, 'en') 112 | self.assertEqual(template.name, 'dummy_template.html') 113 | 114 | def test_on_missing_content_return_fallback(self): 115 | self.mock_fs.read_file.return_value = None 116 | template, _ = reader.read('.', self.email) 117 | self.assertEqual(self.mock_fs.read_file.call_count, 2) 118 | 119 | def test_on_malformed_content_return_fallback(self): 120 | malformed_email_content = """ 121 | 123 | 124 | 125 | """ 126 | self.mock_fs.read_file.side_effect = iter([malformed_email_content, 127 | self.email_content, 128 | self.template_str, 129 | 'test']) 130 | template, _ = reader.read('.', self.email) 131 | self.assertEqual(self.mock_fs.read_file.call_count, 4) 132 | 133 | 134 | class TestWriter(TestCase): 135 | def setUp(self): 136 | self.maxDiff = None 137 | 138 | self.patch_fs = patch('email_parser.reader.fs') 139 | self.mock_fs = self.patch_fs.start() 140 | self.mock_fs.read_file.return_value = 'test' 141 | 142 | self.template_str = '{{subject}}{{content}}{{global_content}}' 143 | self.mock_fs.read_file.side_effect = iter([self.template_str]) 144 | 145 | def elements_equal(self, e1, e2): 146 | self.assertEqual(e1.tag, e2.tag) 147 | self.assertEqual(e1.text, e2.text) 148 | self.assertEqual(e1.tail, e2.tail) 149 | self.assertEqual(e1.attrib, e2.attrib) 150 | self.assertEqual(len(e1), len(e2)) 151 | return all(self.elements_equal(c1, c2) for c1, c2 in zip(e1, e2)) 152 | 153 | def tearDown(self): 154 | super().tearDown() 155 | self.patch_fs.stop() 156 | 157 | def test_create_email_content(self): 158 | expected = read_fixture('email.xml').strip() 159 | placeholders = [ 160 | Placeholder('content', 'dummy content'), 161 | Placeholder('subject', 'dummy subject', False, PlaceholderType.text, {'B': 'better subject'}), 162 | ] 163 | 164 | result = reader.create_email_content('dummy_root', 'basic_template.html', ['style1.css'], placeholders, 165 | EmailType.transactional) 166 | self.assertMultiLineEqual(expected, result.strip()) 167 | 168 | def test_create_content_with_type(self): 169 | expected = read_fixture('email_with_type.xml').strip() 170 | placeholders = [ 171 | Placeholder('content', 'dummy content'), 172 | Placeholder('subject', 'dummy subject', False, PlaceholderType.text, {'B': 'better subject'}), 173 | ] 174 | 175 | result = reader.create_email_content('dummy_root', 'dummy_template_name.html', ['style1.css'], placeholders, 176 | EmailType.transactional) 177 | self.assertMultiLineEqual(expected, result.strip()) 178 | 179 | def test_create_content_with_metaplaceholder(self): 180 | expected_xml = read_fixture('email_inference.xml', etree.XML) 181 | tpl = read_fixture('email_inference_raw.html').strip() 182 | bitmap_attr = { 183 | 'max-width': '160px', 184 | 'max-height': '160px' 185 | } 186 | placeholders = [ 187 | BitmapPlaceholder('MY_BITMAP', 'ID', 'SRC_LINK', 'ALT_TEXT', None, None, **bitmap_attr), 188 | ] 189 | self.mock_fs.read_file.side_effect = iter([tpl]) 190 | result = reader.create_email_content('dummy_root', 'basic_template.html', ['style1.css'], placeholders, 191 | EmailType.transactional) 192 | xml_result = etree.fromstring(result) 193 | self.elements_equal(expected_xml, xml_result) 194 | 195 | 196 | class TestParsing(TestCase): 197 | def test_parsing_meta_complex(self): 198 | placeholder_str = 'text:name:arg1=0;arg2=abcd' 199 | expected = MetaPlaceholder('name', PlaceholderType.text, {'arg1': '0', 'arg2': 'abcd'}) 200 | result = reader.parse_placeholder(placeholder_str) 201 | self.assertEqual(result, expected) 202 | 203 | def test_parsing_meta_simple(self): 204 | placeholder_str = 'name' 205 | expected = MetaPlaceholder('name') 206 | result = reader.parse_placeholder(placeholder_str) 207 | self.assertEqual(result, expected) 208 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import TestCase 3 | from unittest.mock import patch 4 | 5 | import email_parser 6 | from email_parser import config 7 | from email_parser.model import EmailType 8 | 9 | 10 | def read_fixture(filename): 11 | with open(os.path.join('tests/fixtures', filename)) as fp: 12 | return fp.read() 13 | 14 | 15 | class TestParser(TestCase): 16 | def setUp(self): 17 | self.parser = email_parser.Parser('./tests') 18 | self.maxDiff = None 19 | 20 | def tearDown(self): 21 | config.init() 22 | 23 | def test_get_template_for_email(self): 24 | email = self.parser.get_template_for_email('email', 'en') 25 | self.assertEqual(email, read_fixture('email.raw.html')) 26 | 27 | def test_parse_email(self): 28 | subject, text, html = self.parser.render('email', 'en') 29 | self.assertEqual(subject, read_fixture('email.subject').strip()) 30 | self.assertEqual(html, read_fixture('email.html')) 31 | self.assertEqual(text, read_fixture('email.text').strip()) 32 | 33 | def test_parse_email_variant(self): 34 | subject, text, html = self.parser.render('email', 'en', 'B') 35 | self.assertEqual(subject, read_fixture('email.b.subject').strip()) 36 | self.assertEqual(html, read_fixture('email.b.html')) 37 | self.assertEqual(text, read_fixture('email.b.text').strip()) 38 | 39 | def test_get_email_names(self): 40 | names = self.parser.get_email_names() 41 | self.assertListEqual( 42 | list(names), [ 43 | 'email', 'email_globale', 'email_order', 'email_render_with_inference', 44 | 'email_subject_resend', 'email_subjects_ab', 'fallback', 'missing_placeholder', 'placeholder' 45 | ]) 46 | 47 | def test_get_email_placeholders(self): 48 | placeholders = self.parser.get_email_placeholders() 49 | self.assertEqual(len(placeholders.keys()), 8) 50 | self.assertCountEqual(placeholders['placeholder'], ['unsubscribe_link', 'placeholder']) 51 | 52 | def test_render(self): 53 | subject, text, html = self.parser.render('placeholder', 'en') 54 | self.assertEqual(text, 'Dummy content {{placeholder}}\n\nDummy inline') 55 | 56 | def test_create_email(self): 57 | placeholders = { 58 | 'subject': { 59 | 'content': "dummy subject", 60 | 'is_text': True, 61 | 'is_global': False, 62 | 'type': 'text', 63 | 'is_global': False, 64 | 'variants': { 65 | 'B': 'better subject' 66 | } 67 | }, 68 | 'content': { 69 | 'content': "dummy content", 70 | 'type': 'text', 71 | 'is_global': False 72 | }, 73 | 'global_content': { 74 | 'content': "global dummy content", 75 | 'type': 'text', 76 | 'is_global': True 77 | }, 78 | } 79 | expected = read_fixture('email.xml').strip() 80 | content = self.parser.create_email_content('basic_template.html', ['style1.css'], placeholders, 'transactional') 81 | self.assertMultiLineEqual(content.strip(), expected.strip()) 82 | 83 | def test_create_email_without_emailtype(self): 84 | placeholders = { 85 | 'subject': { 86 | 'content': "dummy subject", 87 | 'is_text': True, 88 | 'is_global': False, 89 | 'type': 'text', 90 | 'is_global': False, 91 | 'variants': { 92 | 'B': 'better subject' 93 | } 94 | }, 95 | 'content': { 96 | 'content': "dummy content", 97 | 'type': 'text', 98 | 'is_global': False 99 | }, 100 | 'global_content': { 101 | 'content': "global dummy content", 102 | 'type': 'text', 103 | 'is_global': True 104 | }, 105 | } 106 | self.parser.create_email_content('basic_template.html', ['style1.css'], placeholders) 107 | 108 | def test_get_template(self): 109 | expected = ['subject', 'color', 'content', 'inline', 'image', 'image_absolute'] 110 | _, actual = self.parser.get_template('basic_template.html', 'transactional') 111 | self.assertEqual(set(actual), set(expected)) 112 | 113 | def test_get_template_without_type(self): 114 | expected = ['subject', 'color', 'content', 'inline', 'image', 'image_absolute'] 115 | _, actual = self.parser.get_template('basic_template.html') 116 | self.assertEqual(set(actual), set(expected)) 117 | 118 | def test_get_resources(self): 119 | basic_template_placeholders = ['subject', 'color', 'content', 'inline', 'image', 'image_absolute'] 120 | globale_template_placeholders = ['subject', 'color', 'content', 'inline', 'image', 'image_absolute', 121 | 'global_unsubscribe'] 122 | actual_templates, styles, sections = self.parser.get_resources() 123 | # are templates separated by type? 124 | self.assertIn('marketing', actual_templates.keys()) 125 | self.assertIn('transactional', actual_templates.keys()) 126 | # are templates assigned to correct type? 127 | self.assertIn('basic_marketing_template.html', actual_templates['marketing'].keys()) 128 | self.assertIn('basic_template.html', actual_templates['transactional'].keys()) 129 | # are placeholders assigned to correct template? 130 | template_html__keys = actual_templates['transactional']['basic_template.html'].keys() 131 | self.assertEqual(basic_template_placeholders, list(template_html__keys)) 132 | template_html__keys = actual_templates['marketing']['globale_template.html'].keys() 133 | self.assertEqual(globale_template_placeholders, list(template_html__keys)) 134 | self.assertIn('header-with-background.html', sections.keys()) 135 | self.assertIn('basic_template.css', styles) 136 | 137 | def test_get_email_filepaths_all_locale(self): 138 | expected = ['src/ar/email.xml', 'src/en/email.xml', 'src/fr/email.xml'] 139 | actual = self.parser.get_email_filepaths('email') 140 | self.assertEqual(actual, expected) 141 | 142 | def test_get_email_filepaths_single_locale(self): 143 | expected = ['src/ar/email.xml'] 144 | actual = self.parser.get_email_filepaths('email', 'ar') 145 | self.assertEqual(actual, expected) 146 | 147 | def test_equality(self): 148 | parserA = email_parser.Parser('./tests') 149 | parserB = email_parser.Parser('./tests') 150 | self.assertEqual(parserA, parserB) 151 | 152 | def test_get_email_components(self): 153 | expected = ('basic_template.html', EmailType.transactional.value, ['basic_template.css'], 154 | { 155 | 'color': { 156 | 'content': '[[#C0D9D9]]', 157 | 'is_global': False, 158 | 'name': 'color', 159 | 'type': 'attribute', 160 | 'variants': {} 161 | }, 162 | 'content': { 163 | 'content': 'Dummy content', 164 | 'is_global': False, 165 | 'name': 'content', 166 | 'type': 'text', 167 | 'variants': { 168 | 'B': 'Awesome content' 169 | } 170 | }, 171 | 'image': { 172 | 'content': '![Alt text](/path/to/img.jpg)', 173 | 'is_global': False, 174 | 'name': 'image', 175 | 'type': 'text', 176 | 'variants': {} 177 | }, 178 | 'image_absolute': { 179 | 'content': '![Alt text](http://path.com/to/{link_locale}/img.jpg)', 180 | 'is_global': False, 181 | 'name': 'image_absolute', 182 | 'type': 'text', 183 | 'variants': {} 184 | }, 185 | 'inline': { 186 | 'content': 'Dummy inline', 187 | 'is_global': False, 188 | 'name': 'inline', 189 | 'type': 'raw', 190 | 'variants': {} 191 | }, 192 | 'subject': { 193 | 'content': 'Dummy subject', 194 | 'is_global': False, 195 | 'name': 'subject', 196 | 'type': 'text', 197 | 'variants': {'B': 'Awesome subject'} 198 | } 199 | }) 200 | actual = self.parser.get_email_components('email', 'en') 201 | self.assertEqual(actual, expected) 202 | 203 | def test_get_email_variants(self): 204 | actual = self.parser.get_email_variants('email') 205 | self.assertEqual(actual, ['B']) 206 | 207 | @patch('email_parser.fs.save_file') 208 | def test_save_email_variant_default_content(self, mock_save): 209 | expected = read_fixture('email_en_default.xml') 210 | self.parser.save_email_variant_as_default('email', ['en'], None) 211 | content, _ = mock_save.call_args[0] 212 | self.assertMultiLineEqual(content.strip(), expected.strip()) 213 | 214 | @patch('email_parser.fs.save_file') 215 | def test_save_email_variant_b_content(self, mock_save): 216 | expected = read_fixture('email_en_b.xml') 217 | self.parser.save_email_variant_as_default('email', ['en'], 'B') 218 | content, _ = mock_save.call_args[0] 219 | self.assertMultiLineEqual(content.strip(), expected.strip()) 220 | 221 | def test_original(self): 222 | actual = self.parser.original('email_order', 'en') 223 | self.assertEqual(actual, read_fixture('original.txt').strip()) 224 | 225 | def test_get_emails_resources_paths(self): 226 | expected = ['templates_html/basic_template.css', 227 | 'templates_html/transactional/basic_template.html'] 228 | actual = self.parser.get_email_resources_filepaths('email') 229 | self.assertEqual(set(expected), set(actual)) 230 | 231 | def test_parse_email_with_inference(self): 232 | subject, text, html = self.parser.render('email_render_with_inference', 'en') 233 | self.assertEqual(html, read_fixture('email_render_with_inference.html')) 234 | -------------------------------------------------------------------------------- /email_parser/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | email_parser 3 | ~~~~~~~~~~ 4 | 5 | Parses emails from a source directory and outputs text and html format. 6 | 7 | :copyright: (c) 2014 by KeepSafe. 8 | :license: Apache, see LICENSE for more details. 9 | """ 10 | import json 11 | import os 12 | 13 | from . import placeholder, fs, reader, renderer, const, config 14 | from .model import * 15 | 16 | 17 | class Parser: 18 | def __init__(self, root_path, **kwargs): 19 | self.root_path = root_path 20 | config.init(**kwargs) 21 | 22 | def __hash__(self): 23 | return hash(self.root_path) 24 | 25 | def __eq__(self, other): 26 | """ 27 | since parser is just wrapper it means essentially that parsers are equal if they work on the same root_path 28 | sorting this out is needed for caching 29 | """ 30 | return self.__hash__() == hash(other) 31 | 32 | def get_template_for_email(self, email_name, locale): 33 | email = fs.email(self.root_path, email_name, locale) 34 | if not email: 35 | return None 36 | template, _ = reader.read(self.root_path, email) 37 | return template.content 38 | 39 | def get_email_type(self, email_name, locale): 40 | email = fs.email(self.root_path, email_name, locale) 41 | return reader.get_email_type(self.root_path, email) 42 | 43 | def render(self, email_name, locale, variant=None): 44 | email = fs.email(self.root_path, email_name, locale) 45 | return self.render_email(email, variant) 46 | 47 | def render_email(self, email, variant=None): 48 | if not email: 49 | return None 50 | template, persisted_placeholders = reader.read(self.root_path, email) 51 | if template: 52 | return renderer.render(email.locale, template, persisted_placeholders, variant) 53 | 54 | def render_email_content(self, content, locale=const.DEFAULT_LOCALE, variant=None, highlight=None): 55 | template, persisted_placeholders = reader.read_from_content(self.root_path, content, locale) 56 | return renderer.render(locale, template, persisted_placeholders, variant=variant, highlight=highlight) 57 | 58 | def get_email(self, email_name, locale): 59 | email = fs.email(self.root_path, email_name, locale) 60 | return fs.read_file(email.path) 61 | 62 | def original(self, email_name, locale, variant=None): 63 | email = fs.email(self.root_path, email_name, locale) 64 | if not email: 65 | return None 66 | template, placeholders = reader.read(self.root_path, email) 67 | return '\n\n'.join([placeholders[name].get_content() for name in template.placeholders if 68 | placeholders[name].type == PlaceholderType.text]) 69 | 70 | def get_email_components(self, email_name, locale): 71 | email = fs.email(self.root_path, email_name, locale) 72 | template, placeholders = reader.read(self.root_path, email) 73 | serialized_placeholders = {name: dict(placeholder) for name, placeholder in placeholders.items()} 74 | return template.name, template.type, template.styles_names, serialized_placeholders 75 | 76 | def get_email_variants(self, email_name): 77 | email = fs.email(self.root_path, email_name, const.DEFAULT_LOCALE) 78 | _, placeholders = reader.read(self.root_path, email) 79 | variants = set([name for _, p in placeholders.items() for name in p.variants.keys()]) 80 | return list(variants) 81 | 82 | def delete_email(self, email_name): 83 | emails = fs.emails(self.root_path, email_name=email_name) 84 | files = [] 85 | for email in emails: 86 | files.append(email.path) 87 | fs.delete_file(email.path) 88 | self.refresh_email_placeholders_config() 89 | return files 90 | 91 | def save_email(self, email_name, locale, content): 92 | saved_path = fs.save_email(self.root_path, content, email_name, locale) 93 | self.refresh_email_placeholders_config() 94 | return saved_path 95 | 96 | def save_email_variant_as_default(self, email_name, locales, variant, email_type=None): 97 | paths = [] 98 | for locale in locales: 99 | email = fs.email(self.root_path, email_name, locale) 100 | template, placeholders = reader.read(self.root_path, email) 101 | placeholders_list = [p.pick_variant(variant) for _, p in placeholders.items() if not p.is_global] 102 | if email_type: 103 | email_type = EmailType(email_type) 104 | elif template.type: 105 | email_type = EmailType(template.type) 106 | content = reader.create_email_content(self.root_path, template.name, template.styles_names, 107 | placeholders_list, email_type) 108 | email_path = fs.save_email(self.root_path, content, email_name, locale) 109 | paths.append(email_path) 110 | return paths 111 | 112 | def create_email_content(self, template_name, styles_names, placeholders, email_type=None): 113 | placeholder_list = [] 114 | non_globals = filter(lambda key: not placeholders[key].get('is_global', False), placeholders.keys()) 115 | for placeholder_name in non_globals: 116 | placeholder_props = placeholders[placeholder_name] 117 | is_global = placeholder_props.get('is_global', False) 118 | variants = placeholder_props.get('variants', {}) 119 | pt = placeholder_props.get('type', PlaceholderType.text.value) 120 | pt = PlaceholderType[pt] 121 | if pt != PlaceholderType.bitmap: 122 | content = placeholder_props['content'] 123 | p = Placeholder(placeholder_name, content, is_global, pt, variants) 124 | else: 125 | bitmap_id = placeholder_props['id'] 126 | bitmap_src = placeholder_props['src'] 127 | bitmap_alt = placeholder_props.get('alt') 128 | bitmap_attr = placeholder_props.get('attributes', {}) 129 | p = BitmapPlaceholder(placeholder_name, bitmap_id, bitmap_src, bitmap_alt, is_global, variants, 130 | **bitmap_attr) 131 | placeholder_list.append(p) 132 | if email_type: 133 | email_type = EmailType(email_type) 134 | return reader.create_email_content(self.root_path, template_name, styles_names, placeholder_list, email_type) 135 | 136 | def render_template_content(self, template_content, styles_names, placeholders, locale=const.DEFAULT_LOCALE): 137 | styles = reader.get_inline_style(self.root_path, styles_names) 138 | placeholders_objs = {name: Placeholder(name, content) for name, content in placeholders.items()} 139 | template = Template('preview', styles_names, styles, template_content, placeholders_objs, None) 140 | return renderer.render(locale, template, placeholders_objs) 141 | 142 | def get_email_names(self): 143 | return (email.name for email in fs.emails(self.root_path, locale=const.DEFAULT_LOCALE)) 144 | 145 | def get_emails(self, locale=const.DEFAULT_LOCALE): 146 | return (email._asdict() for email in fs.emails(self.root_path, locale=locale)) 147 | 148 | def get_email_placeholders(self): 149 | expected_placeholders = placeholder.expected_placeholders_file(self.root_path) 150 | return {k: list(v) for k, v in expected_placeholders.items()} 151 | 152 | def get_template(self, template_filename, template_type=None): 153 | try: 154 | template_type = EmailType(template_type) 155 | except ValueError: 156 | template_type = None 157 | content, placeholders = reader.get_template_parts(self.root_path, template_filename, template_type) 158 | return content, placeholders 159 | 160 | def save_template(self, template_filename, template_type, template_content): 161 | template_type = EmailType(template_type) 162 | return fs.save_template(self.root_path, template_filename, template_type, template_content) 163 | 164 | def refresh_email_placeholders_config(self): 165 | placeholders_config = placeholder.generate_config(self.root_path) 166 | if placeholders_config: 167 | fs.save_file( 168 | json.dumps(placeholders_config, sort_keys=True, indent=const.JSON_INDENT), 169 | self.get_placeholders_filepath()) 170 | placeholder.expected_placeholders_file.cache_clear() 171 | 172 | def get_placeholders_filepath(self): 173 | return os.path.join(self.root_path, const.REPO_SRC_PATH, const.PLACEHOLDERS_FILENAME) 174 | 175 | def get_templates_directory_filepath(self): 176 | return os.path.join(self.root_path, config.paths.templates) 177 | 178 | def get_email_filepaths(self, email_name, locale=None): 179 | """ 180 | return list of file paths for single email or collection of emails if locale = None 181 | :param email_name: 182 | :param locale: 183 | :return: 184 | """ 185 | emails = fs.emails(self.root_path, email_name, locale) 186 | abs_paths = map(lambda email: fs.get_email_filepath(email.name, email.locale), emails) 187 | return list(abs_paths) 188 | 189 | def get_email_resources_filepaths(self, email_name): 190 | email = fs.email(self.root_path, email_name, 'en') 191 | if not email: 192 | return None 193 | template, _ = reader.read(self.root_path, email) 194 | file_paths = fs.get_template_resources_filepaths(self.root_path, template) 195 | return list(map(lambda p: str(p.relative_to(self.root_path)), file_paths)) 196 | 197 | def get_email_placeholders_validation_errors(self, email_name, locale): 198 | email = fs.email(self.root_path, email_name, locale) 199 | return placeholder.get_email_validation(self.root_path, email)['errors'] 200 | 201 | def get_resources(self): 202 | templates_view = {} 203 | templates, styles = fs.resources(self.root_path) 204 | sections_map = fs.get_html_sections_map(self.root_path) 205 | for template_type in templates: 206 | types_templates = templates[template_type] 207 | templates_view_type = templates_view.setdefault(template_type, {}) 208 | for template_name in types_templates: 209 | tpl_content, tpl_placeholders = self.get_template(template_name, template_type) 210 | templates_view_type[template_name] = tpl_placeholders 211 | return templates_view, styles, sections_map 212 | 213 | def get_global_placeholders_map(self, locale=const.DEFAULT_LOCALE): 214 | global_placeholders = reader.get_global_placeholders(self.root_path, locale) 215 | return {name: placeholder.get_content() for name, placeholder in global_placeholders.items()} 216 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /email_parser/reader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extracts email information from an email file. 3 | """ 4 | 5 | import logging 6 | import re 7 | from collections import OrderedDict 8 | 9 | from lxml import etree 10 | 11 | from . import fs, const, config 12 | from .model import * 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def parse_placeholder(placeholder_str): 18 | args = {} 19 | regex = r"(.*):(.*):(.*)" 20 | try: 21 | match = re.match(regex, placeholder_str) 22 | placeholder_type = match.group(1) 23 | name = match.group(2) 24 | args_str = match.group(3) 25 | except AttributeError: 26 | return MetaPlaceholder(placeholder_str) 27 | try: 28 | placeholder_type = PlaceholderType(placeholder_type) 29 | except ValueError: 30 | raise ValueError('Placeholder definition %s uses invalid PlaceholderType' % placeholder_str) 31 | for attr_str in args_str.split(';'): 32 | try: 33 | attr_name, attr_value = attr_str.split('=') 34 | args[attr_name] = attr_value 35 | except ValueError: 36 | ValueError('Malformed attributes definition: %s'.format(args_str)) 37 | return MetaPlaceholder(name, placeholder_type, args) 38 | 39 | 40 | def _placeholders(tree, prefix=''): 41 | if tree is None: 42 | return {} 43 | is_global = (prefix == const.GLOBALS_PLACEHOLDER_PREFIX) 44 | result = OrderedDict() 45 | for element in tree.xpath('./string | ./string-array | bitmap | ./array'): 46 | name = '{0}{1}'.format(prefix, element.get('name')) 47 | placeholder_type = PlaceholderType[element.get('type', PlaceholderType.text.value)] 48 | opt_attrs = dict(element.items()) 49 | del opt_attrs['name'] 50 | try: 51 | del opt_attrs['type'] 52 | except KeyError: 53 | pass 54 | if element.tag == 'string': 55 | content = element.text or '' 56 | result[name] = Placeholder(name, content.strip(), is_global, placeholder_type, None, opt_attrs) 57 | elif element.tag == 'bitmap': 58 | del opt_attrs['src'] 59 | del opt_attrs['id'] 60 | p_id = element.get('id') 61 | src = element.get('src') 62 | alt = element.get('alt') or None 63 | if alt: 64 | del opt_attrs['alt'] 65 | result[name] = BitmapPlaceholder(name, p_id, src, alt, is_global, None, **opt_attrs) 66 | elif element.tag in ['string-array', 'array']: 67 | content = '' 68 | variants = {} 69 | for item in element.findall('./item'): 70 | variant = item.get('variant') 71 | if variant: 72 | variants[variant] = item.text.strip() 73 | else: 74 | content = item.text.strip() 75 | result[name] = Placeholder(name, content, is_global, placeholder_type, variants, opt_attrs) 76 | else: 77 | raise Exception('Unknown tag:\n%s' % element) 78 | 79 | return result 80 | 81 | 82 | def get_template_parts(root_path, template_filename, template_type): 83 | content = None 84 | placeholders = OrderedDict() 85 | 86 | try: 87 | template_type = EmailType(template_type) 88 | except ValueError: 89 | template_type = None 90 | 91 | if template_type: 92 | template_path = str(fs.get_template_filepath(root_path, template_filename, template_type.value)) 93 | content = fs.read_file(template_path) 94 | else: 95 | logger.warning('FIXME: no email_type set for: %s, trying all types..', template_filename) 96 | for email_type in EmailType: 97 | try: 98 | template_path = str(fs.get_template_filepath(root_path, template_filename, email_type.value)) 99 | content = fs.read_file(template_path) 100 | break 101 | except FileNotFoundError: 102 | continue 103 | 104 | for m in re.finditer(r'{{(.+?)}}', content): 105 | placeholder_def = m.group(1) 106 | placeholder_meta = parse_placeholder(placeholder_def) 107 | placeholders[placeholder_meta.name] = placeholder_meta 108 | 109 | try: 110 | # TODO sad panda, refactor 111 | # base_url placeholder is not a content block 112 | del placeholders['base_url'] 113 | except KeyError: 114 | pass 115 | 116 | return content, placeholders 117 | 118 | 119 | def get_inline_style(root_path, styles_names): 120 | if not len(styles_names): 121 | return '' 122 | css = [fs.read_file(root_path, config.paths.templates, f) or ' ' for f in styles_names] 123 | styles = '\n'.join(css) 124 | return '' % styles 125 | 126 | 127 | def _template(root_path, tree): 128 | styles = '' 129 | styles_names = [] 130 | 131 | template_filename = tree.getroot().get('template') 132 | email_type = tree.getroot().get('email_type') 133 | content, placeholders = get_template_parts(root_path, template_filename, email_type) 134 | style_element = tree.getroot().get('style') 135 | 136 | if style_element: 137 | styles_names = style_element.split(',') 138 | styles = get_inline_style(root_path, styles_names) 139 | 140 | # TODO either read all or leave just names for content and styles 141 | return Template(template_filename, styles_names, styles, content, placeholders, email_type) 142 | 143 | 144 | def _handle_xml_parse_error(file_path, exception): 145 | pos = exception.position 146 | with open(file_path) as f: 147 | lines = f.read().splitlines() 148 | error_line = lines[pos[0] - 1] 149 | node_matches = re.findall(const.SEGMENT_REGEX, error_line[:pos[1]]) 150 | segment_id = None 151 | 152 | if not len(node_matches): 153 | prev_line = pos[0] - 1 154 | search_part = ''.join(lines[:prev_line]) 155 | node_matches = re.findall(const.SEGMENT_REGEX, search_part) 156 | 157 | if len(node_matches): 158 | name_matches = re.findall(const.SEGMENT_NAME_REGEX, node_matches[-1]) 159 | if len(name_matches): 160 | segment_id = name_matches[-1] 161 | logger.exception('Unable to read content from %s\n%s\nSegment ID: %s\n_______________\n%s\n%s\n', file_path, 162 | exception, segment_id, error_line.replace('\t', ' '), " " * exception.position[1] + "^") 163 | 164 | 165 | def _read_xml(path): 166 | if not path: 167 | return None 168 | try: 169 | parser = etree.XMLParser(encoding='utf-8') 170 | return etree.parse(path, parser=parser) 171 | except etree.ParseError as e: 172 | _handle_xml_parse_error(path, e) 173 | return None 174 | 175 | 176 | def _read_xml_from_content(content): 177 | if not content: 178 | return None 179 | try: 180 | parser = etree.XMLParser(encoding='utf-8') 181 | root = etree.fromstring(content.encode('utf-8'), parser=parser) 182 | return etree.ElementTree(root) 183 | except etree.ParseError as e: 184 | logger.exception('Unable to parse XML content %s %s', content, e) 185 | return None 186 | except TypeError: 187 | # got None? no results 188 | return None 189 | 190 | 191 | def _sort_from_template(root_path, template_filename, template_type, placeholders): 192 | _, placeholders_ordered = get_template_parts(root_path, template_filename, template_type) 193 | ordered_placeholders_names = list(placeholders_ordered.keys()) 194 | placeholders.sort( 195 | key=lambda item: ordered_placeholders_names.index(item.name) if item.name in ordered_placeholders_names else 99) 196 | 197 | 198 | def create_email_content(root_path, template_name, styles, placeholders, email_type): 199 | root = etree.Element('resources') 200 | root.set('template', template_name) 201 | root.set('style', ','.join(styles)) 202 | if email_type: 203 | root.set('email_type', email_type.value) 204 | _sort_from_template(root_path, template_name, email_type, placeholders) 205 | for placeholder in placeholders: 206 | if placeholder.variants: 207 | new_content_tag = etree.SubElement(root, 'string-array', { 208 | 'name': placeholder.name, 209 | 'type': placeholder.type.value or PlaceholderType.text.value 210 | }) 211 | default_item_tag = etree.SubElement(new_content_tag, 'item') 212 | default_item_tag.text = etree.CDATA(placeholder.get_content()) 213 | for variant_name, variant_content in placeholder.variants.items(): 214 | new_item_tag = etree.SubElement(new_content_tag, 'item', {'variant': variant_name}) 215 | new_item_tag.text = etree.CDATA(variant_content) 216 | elif placeholder.type == PlaceholderType.bitmap: 217 | attr = { 218 | 'name': placeholder.name, 219 | 'type': placeholder.type.value, 220 | 'id': placeholder.id, 221 | 'src': placeholder.src 222 | } 223 | if placeholder.alt: 224 | attr['alt'] = placeholder.alt 225 | try: 226 | etree.SubElement(root, 'bitmap', attr) 227 | except TypeError: 228 | msg = 'Cannot create xml element with attrs: %s' % attr 229 | raise TypeError(msg) 230 | else: 231 | new_content_tag = etree.SubElement(root, 'string', { 232 | 'name': placeholder.name, 233 | 'type': placeholder.type.value or PlaceholderType.text.value 234 | }) 235 | new_content_tag.text = etree.CDATA(placeholder.get_content()) 236 | xml_as_str = etree.tostring(root, encoding='utf8', pretty_print=True) 237 | return xml_as_str.decode('utf-8') 238 | 239 | 240 | def get_global_placeholders(root_path, locale): 241 | globals_xml = _read_xml(fs.global_email(root_path, locale).path) 242 | return _placeholders(globals_xml, const.GLOBALS_PLACEHOLDER_PREFIX) 243 | 244 | 245 | def get_inferred_placeholders(meta_placeholders, placeholders): 246 | inferred_placeholders = OrderedDict(placeholders) 247 | for placeholder_name in placeholders: 248 | meta_placeholder = meta_placeholders.get(placeholder_name) 249 | if meta_placeholder and meta_placeholder.attributes: 250 | attr = meta_placeholder.attributes 251 | inferred_placeholders[placeholder_name].set_attr(attr) 252 | return inferred_placeholders 253 | 254 | 255 | def read_from_content(root_path, email_content, locale): 256 | email_xml = _read_xml_from_content(email_content) 257 | if not email_xml: 258 | return None, None 259 | template = _template(root_path, email_xml) 260 | if not template.name: 261 | logger.error('no HTML template name defined for given content') 262 | global_placeholders = get_global_placeholders(root_path, locale) 263 | placeholders = OrderedDict({name: content for name, content 264 | in global_placeholders.items() 265 | if name in template.placeholders}) 266 | placeholders.update(_placeholders(email_xml).items()) 267 | inferred_placeholders = get_inferred_placeholders(template.placeholders, placeholders) 268 | 269 | return template, inferred_placeholders 270 | 271 | 272 | def read(root_path, email): 273 | """ 274 | Reads an email from a path. 275 | 276 | :param root_path: root path of repository 277 | :param email: instance of Email namedtuple 278 | :returns: tuple of email template, a collection of placeholders 279 | """ 280 | email_content = fs.read_file(email.path) 281 | results = read_from_content(root_path, email_content, email.locale) 282 | if not results[0] and email.locale != const.DEFAULT_LOCALE: 283 | email = fs.email(root_path, email.name, const.DEFAULT_LOCALE) 284 | email_content = fs.read_file(email.path) 285 | results = read_from_content(root_path, email_content, email.locale) 286 | 287 | return results 288 | 289 | 290 | def get_email_type(root_path, email): 291 | email_content = fs.read_file(email.path) 292 | email_xml = _read_xml_from_content(email_content) 293 | return email_xml.getroot().get('email_type') 294 | -------------------------------------------------------------------------------- /tests/test_renderer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from unittest.mock import patch 3 | 4 | from email_parser.model import * 5 | from email_parser import renderer, const, config 6 | 7 | 8 | class TestTextRenderer(TestCase): 9 | def setUp(self): 10 | self.email_locale = 'locale' 11 | self.template = Template('dummy', [], '', '{{content1}}', 12 | ['content', 'content1', 'content2'], None) 13 | self.r = renderer.TextRenderer(self.template, self.email_locale) 14 | 15 | def test_happy_path(self): 16 | placeholders = {'content': Placeholder('content', 'dummy content')} 17 | actual = self.r.render(placeholders) 18 | self.assertEqual('dummy content', actual) 19 | 20 | def test_render_variant(self): 21 | placeholders = {'content': Placeholder('content', 'dummy content', variants={'B': 'awesome content'})} 22 | actual = self.r.render(placeholders, variant='B') 23 | self.assertEqual('awesome content', actual) 24 | 25 | def test_concat_multiple_placeholders(self): 26 | placeholders = { 27 | 'content1': Placeholder('content', 'dummy content'), 28 | 'content2': Placeholder('content2', 'dummy content') 29 | } 30 | expected = const.TEXT_EMAIL_PLACEHOLDER_SEPARATOR.join(['dummy content', 'dummy content']) 31 | actual = self.r.render(placeholders) 32 | self.assertEqual(expected, actual) 33 | 34 | def test_concat_multiple_placeholders_with_variants(self): 35 | placeholders = { 36 | 'content1': Placeholder('content', 'dummy content'), 37 | 'content2': Placeholder('content2', 'dummy content', variants={'B': 'awesome content'}) 38 | } 39 | expected = const.TEXT_EMAIL_PLACEHOLDER_SEPARATOR.join(['dummy content', 'awesome content']) 40 | actual = self.r.render(placeholders, variant='B') 41 | self.assertEqual(expected, actual) 42 | 43 | def test_ignore_subject(self): 44 | placeholders = { 45 | 'content': Placeholder('content', 'dummy content'), 46 | 'subject': Placeholder('subject', 'dummy subject') 47 | } 48 | actual = self.r.render(placeholders) 49 | self.assertEqual('dummy content', actual) 50 | 51 | def test_ignore_empty_placeholders(self): 52 | placeholders = {'content': Placeholder('content', 'dummy content'), 'empty': Placeholder('empty', '')} 53 | actual = self.r.render(placeholders) 54 | self.assertEqual('dummy content', actual) 55 | 56 | def test_ignored_placeholders(self): 57 | placeholders = { 58 | 'content': Placeholder('content', 'dummy content'), 59 | 'ignore': Placeholder('ignore', 'test', False) 60 | } 61 | r = renderer.TextRenderer(self.template, self.email_locale) 62 | actual = r.render(placeholders) 63 | self.assertEqual('dummy content', actual) 64 | 65 | def test_use_text_and_url_for_links(self): 66 | placeholders = {'content': Placeholder('content', 'dummy [link_text](http://link_url) content')} 67 | actual = self.r.render(placeholders) 68 | self.assertEqual('dummy link_text (http://link_url) content', actual) 69 | 70 | def test_default_link_locale_for_links(self): 71 | placeholders = { 72 | 'content': Placeholder('content', 'dummy [link_text](http://link_url?locale={link_locale}) content') 73 | } 74 | actual = self.r.render(placeholders) 75 | self.assertEqual('dummy link_text (http://link_url?locale=locale) content', actual) 76 | 77 | def test_link_locale_for_links(self): 78 | self.email_locale = 'pt-BR' 79 | placeholders = { 80 | 'content': Placeholder('content', 'dummy [link_text](http://link_url?locale={link_locale}) content') 81 | } 82 | r = renderer.TextRenderer(self.template, self.email_locale) 83 | actual = r.render(placeholders) 84 | self.assertEqual('dummy link_text (http://link_url?locale=pt) content', actual) 85 | 86 | def test_use_text_if_href_is_empty(self): 87 | placeholders = {'content': Placeholder('content', 'dummy [http://link_url]() content')} 88 | actual = self.r.render(placeholders) 89 | self.assertEqual('dummy http://link_url content', actual) 90 | 91 | def test_use_href_if_text_is_same(self): 92 | placeholders = {'content': Placeholder('content', 'dummy [http://link_url](http://link_url) content')} 93 | actual = self.r.render(placeholders) 94 | self.assertEqual('dummy http://link_url content', actual) 95 | 96 | def test_url_with_params(self): 97 | placeholders = { 98 | 'content': Placeholder('content', 'dummy [param_link](https://something.com/thing?id=mooo) content') 99 | } 100 | actual = self.r.render(placeholders) 101 | self.assertEqual('dummy param_link (https://something.com/thing?id=mooo) content', actual) 102 | 103 | def test_unordered_list(self): 104 | placeholders = {'content': Placeholder('content', '- one\n- two\n- three')} 105 | actual = self.r.render(placeholders) 106 | self.assertEqual('- one\n- two\n- three', actual.strip()) 107 | 108 | def test_ordered_list(self): 109 | placeholders = {'content': Placeholder('content', '1. one\n2. two\n3. three')} 110 | actual = self.r.render(placeholders) 111 | self.assertEqual('1. one\n2. two\n3. three', actual.strip()) 112 | 113 | 114 | class TestSubjectRenderer(TestCase): 115 | def setUp(self): 116 | self.r = renderer.SubjectRenderer() 117 | self.placeholders = { 118 | 'content': Placeholder('content', 'dummy content'), 119 | 'subject': Placeholder('subject', 'dummy subject', variants={'B': 'experiment subject'}) 120 | } 121 | 122 | def test_happy_path(self): 123 | actual = self.r.render(self.placeholders) 124 | self.assertEqual('dummy subject', actual) 125 | 126 | def test_variant(self): 127 | actual = self.r.render(self.placeholders, variant='B') 128 | self.assertEqual('experiment subject', actual) 129 | 130 | def test_raise_error_for_missing_subject(self): 131 | placeholders = {'content': 'dummy content'} 132 | with self.assertRaises(MissingSubjectError): 133 | self.r.render(placeholders) 134 | 135 | 136 | class TestHtmlRenderer(TestCase): 137 | def _get_renderer(self, template_html, template_placeholders, **kwargs): 138 | template = Template( 139 | name='template_name', 140 | styles_names=['template_style.css', 'template_style2.css'], 141 | styles='', 142 | content=template_html, 143 | placeholders=template_placeholders, 144 | type=None) 145 | return renderer.HtmlRenderer(template, kwargs.get('email_locale', const.DEFAULT_LOCALE)) 146 | 147 | def setUp(self): 148 | self.email_locale = 'locale' 149 | config.init(_base_img_path='images_base') 150 | 151 | def tearDown(self): 152 | config.init() 153 | 154 | def test_happy_path(self): 155 | placeholders = {'content1': Placeholder('content1', 'text1')} 156 | template = Template('dummy', [], '', '{{content1}}', ['content1'], None) 157 | r = renderer.HtmlRenderer(template, self.email_locale) 158 | 159 | actual = r.render(placeholders) 160 | self.assertEqual('

text1

', actual) 161 | 162 | def test_variant(self): 163 | placeholders = {'content1': Placeholder('content1', 'text1', variants={'B': 'text2'})} 164 | template = Template('dummy', [], '', '{{content1}}', ['content1'], None) 165 | r = renderer.HtmlRenderer(template, self.email_locale) 166 | 167 | actual = r.render(placeholders, variant='B') 168 | self.assertEqual('

text2

', actual) 169 | 170 | def test_empty_style(self): 171 | placeholders = {'content': Placeholder('content', 'dummy_content')} 172 | template = Template('dummy', [], '', '{{content}}', ['content1'], None) 173 | r = renderer.HtmlRenderer(template, self.email_locale) 174 | 175 | actual = r.render(placeholders) 176 | self.assertEqual('

dummy_content

', actual) 177 | 178 | def test_include_base_url(self): 179 | template = Template('dummy', [], '', '{{base_url}}', ['base_url'], None) 180 | placeholders = {} 181 | r = renderer.HtmlRenderer(template, self.email_locale) 182 | 183 | actual = r.render(placeholders) 184 | self.assertEqual('images_base', actual) 185 | 186 | def test_fail_on_missing_placeholders(self): 187 | template = Template('dummy', [], '', '{{content}}{{missing}}', 188 | ['content', 'missing'], None) 189 | r = renderer.HtmlRenderer(template, self.email_locale) 190 | placeholders = {'content': Placeholder('content', 'dummy_content')} 191 | 192 | with self.assertRaises(MissingTemplatePlaceholderError): 193 | r.render(placeholders) 194 | 195 | def test_rtl_locale(self): 196 | email_locale = 'he' 197 | template = Template('dummy', [], '', '{{content}}', ['content'], None) 198 | r = renderer.HtmlRenderer(template, email_locale) 199 | placeholders = {'content': Placeholder('content', 'dummy_content')} 200 | 201 | actual = r.render(placeholders) 202 | self.assertEqual('\n

\n dummy_content\n

\n', actual) 203 | 204 | def test_rtl_two_placeholders(self): 205 | email_locale = 'ar' 206 | template = Template('dummy', [], '', 207 | '
{{content1}}
{{content2}}
', ['content1', 'content2'], 208 | None) 209 | r = renderer.HtmlRenderer(template, email_locale) 210 | placeholders = { 211 | 'content1': Placeholder('content1', 'dummy_content1'), 212 | 'content2': Placeholder('content2', 'dummy_content2') 213 | } 214 | 215 | actual = r.render(placeholders) 216 | expected = '\n
\n

\n dummy_content1\n

\n
\n
\n

\n dummy_content2\n

\n
\ 217 | \n' 218 | 219 | self.assertEqual(expected, actual) 220 | 221 | def test_inline_styles(self): 222 | template = Template('dummy', [], '', '{{content}}', ['content'], None) 223 | r = renderer.HtmlRenderer(template, self.email_locale) 224 | placeholders = {'content': Placeholder('content', 'dummy_content')} 225 | 226 | actual = r.render(placeholders) 227 | self.assertEqual('

dummy_content

', actual) 228 | 229 | @patch('email_parser.fs.read_file') 230 | def test_no_tracking(self, mock_read): 231 | html = '{{content}}' 232 | html_placeholders = ['content'] 233 | placeholders = {'content': Placeholder('content', '[link_title](!http://link.com)', True, False)} 234 | mock_read.side_effect = iter(['']) 235 | expected = """

236 | link_title 237 |

""" 238 | 239 | r = self._get_renderer(html, html_placeholders) 240 | actual = r.render(placeholders) 241 | 242 | self.assertEqual(expected, actual) 243 | 244 | def test_empty_placeholders_rendering(self): 245 | template = Template('dummy', [], '', '{{content}}', ['content'], None) 246 | r = renderer.HtmlRenderer(template, self.email_locale) 247 | placeholders = {'content': Placeholder('content', '')} 248 | 249 | actual = r.render(placeholders) 250 | self.assertEqual('', actual) 251 | 252 | def test_transform_extended_tags(self): 253 | content = '{{bitmap:MY_BITMAP:max-width=160;max-height=160;}}' 254 | expected = '{{MY_BITMAP}}' 255 | result = renderer._transform_extended_tags(content) 256 | self.assertEqual(result, expected) 257 | --------------------------------------------------------------------------------