diff --git a/.travis.yml b/.travis.yml index 80a698fa..121d61eb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,19 @@ before_script: script: - make lint - make test +- python setup.py build after_success: - ls -lha - bash <(curl -s https://codecov.io/bash) + +deploy: +- provider: pypi + user: samuelcolvin + password: + secure: "cmrF8mXOryNCMrTOg/1pZ5a8eN+rQDjmwPeQuud6AMvPxBfIP0F3okUhiusfq7tth8mwEOuz1FUAlHXLHdZP22HgxV/w/p/5wqDlzeIsszihUyXKnFuh/Q48zCBL9fqqIkQEBZFys6CyzHlSJjfGTncxTCCGY5Io/235lamgjNxW75SM/qZgvxEAve5l/yO33wmnk17HZsJ8Wdv181hegV/MPeOpI3HH+K3xrLI+VgclYlGcXv88Bveoms7CoKNsklbqHuOb6GcCjXM5+KzYE0Gv8Hv9xFp13qcxhIKyzPOT8zIUkCRNbUJ9YWXueVCqOZYJST1XdBD1V2RiDjthkpsuOe8N5kdegwciDGAt8elPFQVCiS1g08jexikqPLsymJyEU8jTsJs9IZNrB/l6fJi5WZ1SaysgYFiXtEF4wv72RwI0CQmFeVSHEfVHuKWXsY0biAwXTKTQnTkQmD4u73Kt1zcyswCDQw760kylSIwuJTBii4dit4XyG2fxL5aUFBFsWNw8jCyZajCAclQNBjaVtaGWdKbT33O8iR/qws4HYAbaDU+kFAsKH7S0jmHhpiwjQ/Hdr+g6adTrkKuk/PXvORWM6oiUUbkuH0wMEK0nKo/fn2p7sd65BycyfcEDrP9y2qdAB8dUdk2pBngF2miYVFBuJMGp9GQwzN8LnH0=" + distributions: sdist bdist_wheel + skip_upload_docs: true + on: + tags: true + python: 3.6 diff --git a/Makefile b/Makefile index 726d75b8..1af23de6 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ isort: lint: flake8 morpheus/ tests/ pytest morpheus -p no:sugar -q + python setup.py check -rms .PHONY: test test: diff --git a/morpheus/app/render/__init__.py b/morpheus/app/render/__init__.py new file mode 100644 index 00000000..955d3945 --- /dev/null +++ b/morpheus/app/render/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa +from .main import EmailInfo, MessageDef, render_email diff --git a/morpheus/app/render/main.py b/morpheus/app/render/main.py new file mode 100644 index 00000000..ec9d1e88 --- /dev/null +++ b/morpheus/app/render/main.py @@ -0,0 +1,89 @@ +import logging +import re +from typing import Dict, NamedTuple + +import chevron +import misaka +from misaka import HtmlRenderer, Markdown + +markdown = Markdown(HtmlRenderer(flags=[misaka.HTML_HARD_WRAP]), extensions=[misaka.EXT_NO_INTRA_EMPHASIS]) +logger = logging.getLogger('morpheus.render') + + +class MessageDef(NamedTuple): + first_name: str + last_name: str + main_template: str + mustache_partials: Dict[str, dict] + macros: Dict[str, dict] + subject_template: str + context: dict + headers: dict + + +class EmailInfo(NamedTuple): + full_name: str + subject: str + html_body: str + headers: dict + + +def _update_context(context, partials, macros): + for k, v in context.items(): + if k.endswith('__md'): + yield k[:-4], markdown(v) + elif k.endswith('__render'): + v = chevron.render( + _apply_macros(v, macros), + data=context, + partials_dict=partials + ) + yield k[:-8], markdown(v) + + +def _apply_macros(s, macros): + if macros: + for key, body in macros.items(): + m = re.search('^(\S+)\((.*)\) *$', key) + if not m: + logger.warning('invalid macro "%s", skipping it', key) + continue + name, arg_defs = m.groups() + arg_defs = [a.strip(' ') for a in arg_defs.split('|') if a.strip(' ')] + + def replace_macro(m): + arg_values = [a.strip(' ') for a in m.groups()[0].split('|') if a.strip(' ')] + if len(arg_defs) != len(arg_values): + logger.warning('invalid macro call "%s", not replacing', m.group()) + return m.group() + else: + return chevron.render(body, data=dict(zip(arg_defs, arg_values))) + + s = re.sub(r'%s\((.*?)\)' % name, replace_macro, s) + return s + + +def render_email(m: MessageDef) -> EmailInfo: + full_name = f'{m.first_name or ""} {m.last_name or ""}'.strip(' ') + m.context.setdefault('recipient_name', full_name) + m.context.setdefault('recipient_first_name', m.first_name or full_name) + m.context.setdefault('recipient_last_name', m.last_name) + subject = chevron.render(m.subject_template, data=m.context) + m.context.update( + subject=subject, + **dict(_update_context(m.context, m.mustache_partials, m.macros)) + ) + unsubscribe_link = m.context.get('unsubscribe_link') + if unsubscribe_link: + m.headers.setdefault('List-Unsubscribe', f'<{unsubscribe_link}>') + + return EmailInfo( + full_name=full_name, + subject=subject, + html_body=chevron.render( + _apply_macros(m.main_template, m.macros), + data=m.context, + partials_dict=m.mustache_partials, + ), + headers=m.headers, + ) diff --git a/morpheus/app/worker.py b/morpheus/app/worker.py index 1a244fe9..37f4ff9d 100644 --- a/morpheus/app/worker.py +++ b/morpheus/app/worker.py @@ -6,15 +6,13 @@ from pathlib import Path from typing import Dict, List, NamedTuple -import chevron -import misaka import msgpack from aiohttp import ClientSession from arq import Actor, BaseWorker, Drain, concurrent -from misaka import HtmlRenderer, Markdown from .es import ElasticSearch from .models import MessageStatus, SendMethod +from .render import EmailInfo, render_email from .settings import Settings from .utils import Mandrill @@ -22,9 +20,6 @@ main_logger = logging.getLogger('morpheus.worker') -markdown = Markdown(HtmlRenderer(flags=[misaka.HTML_HARD_WRAP]), extensions=[misaka.EXT_NO_INTRA_EMPHASIS]) - - class Job(NamedTuple): group_id: str send_method: str @@ -46,14 +41,6 @@ class Job(NamedTuple): headers: dict -class EmailInfo(NamedTuple): - full_name: str - subject: str - html_body: str - signing_domain: str - headers: dict - - class Sender(Actor): def __init__(self, settings: Settings=None, **kwargs): self.settings = settings or Settings() @@ -138,7 +125,7 @@ async def send(self, return jobs async def _send_mandrill(self, j: Job): - email_info = self._get_email_info(j) + email_info = render_email(j) data = { 'async': True, 'message': dict( @@ -157,7 +144,7 @@ async def _send_mandrill(self, j: Job): track_opens=True, auto_text=True, view_content_link=False, - signing_domain=email_info.signing_domain, + signing_domain=j.from_email[j.from_email.index('@') + 1:], subaccount=j.subaccount, tags=j.tags, inline_css=True, @@ -180,7 +167,7 @@ async def _send_mandrill(self, j: Job): await self._store_msg(data['_id'], send_ts, j, email_info) async def _send_test(self, j: Job): - email_info = self._get_email_info(j) + email_info = render_email(j) data = dict( from_email=j.from_email, from_name=j.from_name, @@ -188,7 +175,6 @@ async def _send_test(self, j: Job): headers=email_info.headers, to_email=j.address, to_name=email_info.full_name, - signing_domain=email_info.signing_domain, tags=j.tags, important=j.important, attachments=[dict( @@ -215,69 +201,6 @@ async def _send_test(self, j: Job): save_path.write_text(output) await self._store_msg(msg_id, send_ts, j, email_info) - @classmethod - def _update_context(cls, context, partials, macros): - for k, v in context.items(): - if k.endswith('__md'): - yield k[:-4], markdown(v) - elif k.endswith('__render'): - v = chevron.render( - cls._apply_macros(v, macros), - data=context, - partials_dict=partials - ) - yield k[:-8], markdown(v) - - @staticmethod - def _apply_macros(s, macros): - if macros: - for key, body in macros.items(): - m = re.search('^(\S+)\((.*)\) *$', key) - if not m: - main_logger.warning('invalid macro "%s", skipping it', key) - continue - name, arg_defs = m.groups() - arg_defs = [a.strip(' ') for a in arg_defs.split('|') if a.strip(' ')] - - def replace_macro(m): - arg_values = [a.strip(' ') for a in m.groups()[0].split('|') if a.strip(' ')] - if len(arg_defs) != len(arg_values): - main_logger.warning('invalid macro call "%s", not replacing', m.group()) - return m.group() - else: - return chevron.render(body, data=dict(zip(arg_defs, arg_values))) - - s = re.sub(r'%s\((.*?)\)' % name, replace_macro, s) - return s - - def _get_email_info(self, j: Job) -> EmailInfo: - full_name = f'{j.first_name} {j.last_name}'.strip(' ') - j.context.update( - first_name=j.first_name, - last_name=j.last_name, - full_name=full_name, - ) - subject = chevron.render(j.subject_template, data=j.context) - j.context.update( - subject=subject, - **dict(self._update_context(j.context, j.mustache_partials, j.macros)) - ) - unsubscribe_link = j.context.get('unsubscribe_link') - if unsubscribe_link: - j.headers.setdefault('List-Unsubscribe', f'<{unsubscribe_link}>') - - return EmailInfo( - full_name=full_name, - subject=subject, - html_body=chevron.render( - self._apply_macros(j.main_template, j.macros), - data=j.context, - partials_dict=j.mustache_partials, - ), - signing_domain=j.from_email[j.from_email.index('@') + 1:], - headers=j.headers, - ) - async def _generate_base64_pdf(self, html): if not self.settings.pdf_generation_url: return 'no-pdf-generated' diff --git a/morpheus/requirements_two.txt b/morpheus/requirements_two.txt index e03a50da..dbe456bd 100644 --- a/morpheus/requirements_two.txt +++ b/morpheus/requirements_two.txt @@ -1,7 +1,7 @@ aiohttp==2.1.0 aioredis==0.3.1 arq==0.8.1 -chevron==0.9.0 +chevron==0.10.0 click==6.7 ipython==6.1.0 misaka==2.1.0 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..462a9494 --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup + +setup( + name='morpheus-mail', + version='0.0.1', + description='Email rendering engine from morpheus', + long_description=""" +Note: this is only installs the rendering logic for morpheus for testing and email preview. + +Everything else is excluded to avoid installing unnecessary packages. +""", + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.6', + 'Intended Audience :: Developers', + 'Intended Audience :: Information Technology', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: MIT License', + 'Operating System :: Unix', + 'Operating System :: POSIX :: Linux', + 'Topic :: Internet', + ], + author='Samuel Colvin', + author_email='s@muelcolvin.com', + url='https://github.com/tutorcruncher/morpheus', + license='MIT', + packages=['morpheus.render'], + package_dir={'morpheus.render': 'morpheus/app/render'}, + python_requires='>=3.6', + zip_safe=True, + install_requires=[ + 'misaka>=2.1.0', + 'chevron>=0.10.0', + ], +) diff --git a/tests/test_main.py b/tests/test_main.py index 9f6884d7..a4e86493 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -346,7 +346,7 @@ async def test_macro_in_message(send_message, tmpdir): assert """ content:
-