From 7fe586be536d0d1b195e122367e98790d42091f6 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Tue, 6 Feb 2024 14:30:44 +0100 Subject: [PATCH 01/10] Implemented HTML post-processor to attach external images as mail attachments, add missing host to link urls --- mail_editor/models.py | 22 ++++++-- mail_editor/process.py | 75 +++++++++++++++++++++++++ mail_editor/settings.py | 9 +++ mail_editor/views.py | 6 +- tests/settings.py | 5 ++ tests/test_process_html.py | 55 +++++++++++++++++++ tests/test_send.py | 94 ++++++++++++++++++++++++++++++++ tests/test_template_rendering.py | 3 - 8 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 mail_editor/process.py create mode 100644 tests/test_process_html.py create mode 100644 tests/test_send.py diff --git a/mail_editor/models.py b/mail_editor/models.py index 43be39d..4c86400 100644 --- a/mail_editor/models.py +++ b/mail_editor/models.py @@ -2,7 +2,9 @@ import logging import os import subprocess +from email.mime.image import MIMEImage from tempfile import NamedTemporaryFile +from xml import etree from django.conf import settings as django_settings from django.core.exceptions import ValidationError @@ -16,6 +18,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ +from .process import process_html from .settings import get_config, settings from .mail_template import validate_template from .node import locate_package_json @@ -144,8 +147,7 @@ def render(self, context, subj_context=None): body_context.update({'content': partial_body}) body = template_function(self.base_template_path, body_context) - # TODO: I made the package.json stuff optional. Maybe it should be removed completely since it adds 3 settings, - # seems for a limited use-case, and it uses subproces... + # inline styles package_json = locate_package_json() if package_json: env = os.environ.copy() @@ -177,12 +179,16 @@ def build_message(self, to_addresses, context, subj_context=None, txt=False, att `(, , [mime type])` """ subject, body = self.render(context, subj_context) + + body, cid_attachments = process_html(body, base_url=settings.BASE_HOST) + text_body = txt or strip_tags(body) email_message = EmailMultiAlternatives( - subject=subject, body=text_body, from_email=django_settings.DEFAULT_FROM_EMAIL, + subject=subject, body=body, from_email=django_settings.DEFAULT_FROM_EMAIL, to=to_addresses, cc=cc_addresses, bcc=bcc_addresses) - email_message.attach_alternative(body, 'text/html') + email_message.content_subtype = "html" + email_message.attach_alternative(text_body, 'text/plain') if attachments: for attachment in attachments: @@ -193,6 +199,14 @@ def build_message(self, to_addresses, context, subj_context=None, txt=False, att else: email_message.attach(*attachment) + if cid_attachments: + for cid, content, subtype in cid_attachments: + subtype = subtype.split("/", maxsplit=1) + assert subtype[0] == "image" + mime_image = MIMEImage(content, _subtype=subtype[1]) + mime_image.add_header('Content-ID', cid) + email_message.attach(mime_image) + return email_message def send_email(self, to_addresses, context, subj_context=None, txt=False, attachments=None, diff --git a/mail_editor/process.py b/mail_editor/process.py new file mode 100644 index 0000000..6a6f990 --- /dev/null +++ b/mail_editor/process.py @@ -0,0 +1,75 @@ +import hashlib + +from lxml import etree + + +def load_image(url): + import requests + # TODO check domains? + # TODO check data urls + r = requests.get(url) + content_type = r.headers["Content-Type"] + if not content_type.startswith("image/"): + raise Exception("not an image file") + return r.content, content_type + + +def make_url_absolute(url, base_url=""): + """ + base_url: https://domain + """ + if not url: + if base_url: + return base_url + else: + return "/" + if "://" in url: + return url + if url[0] != "/": + url = f"/{url}" + return base_url + url + + +def process_html(html, base_url="", extract_attachments=True, fix_links=True): + parser = etree.HTMLParser() + root = etree.fromstring(html, parser) + + image_attachments = dict() + url_cid_cache = dict() + + if extract_attachments: + # extract and swap related content ID's + for img in root.iterfind(".//img"): + url = img.get("src") + if not url: + continue + # cache cid & content for deduplication (eg: icons) + if url in url_cid_cache: + cid = url_cid_cache[url] + else: + url = make_url_absolute(url, base_url) + content, content_type = load_image(url) + cid = cid_for_bytes(content) + image_attachments[cid] = (content, content_type) + img.set("src", f"cid:{cid}") + + if fix_links: + for a in root.iterfind(".//a"): + url = a.get("href") + if not url: + continue + a.set("href", make_url_absolute(url, base_url)) + + result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html") + attachments = [(cid, content, ct) for cid, (content, ct) in image_attachments.items()] + + return result.decode("utf8"), attachments + + +def cid_for_bytes(content): + # let's hash content for de-duplication + h = hashlib.md5() + h.update(content) + return h.hexdigest() + + diff --git a/mail_editor/settings.py b/mail_editor/settings.py index 809e8fa..af90947 100644 --- a/mail_editor/settings.py +++ b/mail_editor/settings.py @@ -57,6 +57,15 @@ def DYNAMIC_CONTEXT(self): if dynamic: return import_string(dynamic) + @property + def BASE_HOST(self): + """ + protocol, domain and (optional) port, no ending slash + + used to retrieve images for embedding and fix URLs + """ + return getattr(django_settings, 'MAIL_EDITOR_BASE_HOST', "") + @property def BASE_TEMPLATE_LOADER(self): return getattr(django_settings, 'MAIL_EDITOR_BASE_TEMPLATE_LOADER', 'mail_editor.helpers.base_template_loader') diff --git a/mail_editor/views.py b/mail_editor/views.py index 549158b..7848fe9 100644 --- a/mail_editor/views.py +++ b/mail_editor/views.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib import admin from django.contrib import messages from django.http import HttpResponse @@ -9,6 +10,7 @@ from django.views.generic.edit import FormMixin, FormView from .models import MailTemplate +from .process import process_html from .utils import variable_help_text @@ -26,6 +28,7 @@ def get(self, request, *args, **kwargs): subject_ctx, body_ctx = template.get_preview_contexts() subject, body = template.render(body_ctx, subject_ctx) + body = process_html(body, extract_attachments=False, base_url=settings.MAIL_EDITOR_BASE_HOST) return HttpResponse(body, content_type="text/html") @@ -63,8 +66,7 @@ def get_context_data(self, **kwargs): }) subject_ctx, body_ctx = self.object.get_preview_contexts() - subject, body = self.object.render(body_ctx, subject_ctx) - + subject, _body = self.object.render(body_ctx, subject_ctx) # our own context data ctx.update({ "subject": subject, diff --git a/tests/settings.py b/tests/settings.py index e803ca6..7e8e30c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -67,3 +67,8 @@ } } +MAIL_EDITOR_BASE_HOST = "http://testserver" + +SILENCED_SYSTEM_CHECKS = [ + "models.W042", # AutoField warning not relevant for tests +] diff --git a/tests/test_process_html.py b/tests/test_process_html.py new file mode 100644 index 0000000..af0f58e --- /dev/null +++ b/tests/test_process_html.py @@ -0,0 +1,55 @@ +from django.test import TestCase + +from mail_editor.process import cid_for_bytes, make_url_absolute, process_html + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class ProcessTestCase(TestCase): + @patch("mail_editor.process.cid_for_bytes", return_value="MY_CID") + @patch("mail_editor.process.load_image", return_value=(b"abc", "image/jpg")) + def test_process_html_extract_images(self, m1, m2): + html = '

' + result, objects = process_html(html) + + expected_html = f'

' + + self.assertEqual(result.rstrip(), expected_html) + self.assertEqual(objects, [("MY_CID", b"abc", "image/jpg")]) + + def test_process_html_fix_urls(self): + html = '

bar

' + result, objects = process_html(html, base_url="https://example.com") + + expected_html = f'

bar

' + + self.assertEqual(result.rstrip(), expected_html) + self.assertEqual(objects, []) + + +class ProcessHelpersTestCase(TestCase): + def test_make_url_absolute(self): + tests = [ + ("http://example.com", "/foo", "http://example.com/foo"), + ("http://example.com", "foo", "http://example.com/foo"), + ("http://example.com", "", "http://example.com"), + ("http://example.com", "http://example.com/foo", "http://example.com/foo"), + ("http://example.com", "", "http://example.com"), + ("", "http://example.com/foo", "http://example.com/foo"), + ("", "foo", "/foo"), + ("", "", "/"), + ] + for i, (base, url, expected) in enumerate(tests): + with self.subTest((i, base, url)): + self.assertEqual(make_url_absolute(url, base), expected) + + def test_cid_for_bytes(self): + self.assertEqual(cid_for_bytes(b"abc"), cid_for_bytes(b"abc")) + self.assertNotEqual(cid_for_bytes(b"123"), cid_for_bytes(b"abc")) + + def test_load_image(self): + # TODO + pass diff --git a/tests/test_send.py b/tests/test_send.py new file mode 100644 index 0000000..f097e7e --- /dev/null +++ b/tests/test_send.py @@ -0,0 +1,94 @@ +from base64 import b64encode + +from django.core import mail +from django.test import TestCase, override_settings +from django.utils.translation import ugettext_lazy as _ + +from mail_editor.helpers import find_template + + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +CONFIG = { + "test_template": { + "name": _("test_template"), + "description": _("Test description"), + "subject_default": _("Important message for {{ id }}"), + "body_default": _("Test mail sent from testcase with {{ id }}"), + "subject": [{"name": "id", "description": ""}], + "body": [{"name": "id", "description": ""}], + }, + "process_template": { + "name": _("test_template"), + "description": _("Test description"), + "subject_default": _("Important message for {{ id }}"), + "body_default": '{{ id }}', + "subject": [{"name": "id", "description": ""}], + "body": [{"name": "id", "description": ""}], + } +} + + +class EmailSendTestCase(TestCase): + def setUp(self): + site_patch = patch("mail_editor.helpers.get_current_site") + current_site_mock = site_patch.start() + + current_site_mock.domain.return_value = "custom.domain.com" + + def tearDown(self): + patch.stopall() + + @override_settings(MAIL_EDITOR_CONF=CONFIG) + def test_send_email(self): + subject_context = {"id": "111"} + body_context = {"id": "111"} + + template = find_template("test_template") + + res = template.send_email( + ["foo@example.com"], body_context, subj_context=subject_context + ) + self.assertEqual(res, 1) + + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0] + self.assertEqual(message.subject, _("Important message for 111")) + self.assertIn(str(_("Test mail sent from testcase with 111")), message.body) + self.assertEqual(message.attachments, []) + + @patch("mail_editor.process.cid_for_bytes", return_value="MY_CID") + @patch("mail_editor.process.load_image", return_value=(b"abc", "image/jpg")) + @override_settings(MAIL_EDITOR_CONF=CONFIG) + def test_send_email_processed_content(self, m0, m1): + subject_context = {"id": "111"} + body_context = {"id": "111"} + + template = find_template("process_template") + + res = template.send_email( + ["foo@example.com"], body_context, subj_context=subject_context + ) + self.assertEqual(res, 1) + + self.assertEqual(len(mail.outbox), 1) + message = mail.outbox[0] + self.assertEqual(message.subject, _("Important message for 111")) + + self.assertNotIn('', message.body) + self.assertIn('', message.body) + + self.assertNotIn('', message.body) + self.assertIn('', message.body) + + self.assertEqual(len(message.attachments), 1) + attach = message.attachments[0] + + self.assertEqual(attach["Content-ID"], "MY_CID") + self.assertEqual(attach["Content-Type"], "image/jpg") + payload = b64encode(b"abc").decode("utf8") + "\n" + self.assertEqual(attach.get_payload(), payload) diff --git a/tests/test_template_rendering.py b/tests/test_template_rendering.py index e62e39d..a64824a 100644 --- a/tests/test_template_rendering.py +++ b/tests/test_template_rendering.py @@ -1,11 +1,8 @@ -import copy from tempfile import TemporaryFile -from django.conf import settings from django.test import TestCase, override_settings from django.utils.translation import ugettext_lazy as _ -from mail_editor.models import MailTemplate from mail_editor.helpers import find_template From 7660ada70a6d7bc565d18f3c3b7e15297a5c4168 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Wed, 7 Feb 2024 11:12:13 +0100 Subject: [PATCH 02/10] Fixed mystery brackets showing up in preview --- mail_editor/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mail_editor/views.py b/mail_editor/views.py index 7848fe9..873d5ff 100644 --- a/mail_editor/views.py +++ b/mail_editor/views.py @@ -27,8 +27,8 @@ def get(self, request, *args, **kwargs): template = self.get_object() subject_ctx, body_ctx = template.get_preview_contexts() - subject, body = template.render(body_ctx, subject_ctx) - body = process_html(body, extract_attachments=False, base_url=settings.MAIL_EDITOR_BASE_HOST) + _subject, body = template.render(body_ctx, subject_ctx) + body, _attachments = process_html(body, extract_attachments=False, base_url=settings.MAIL_EDITOR_BASE_HOST) return HttpResponse(body, content_type="text/html") From 34004611f24ef80a8181332539fabeead38f213f Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Wed, 7 Feb 2024 11:13:23 +0100 Subject: [PATCH 03/10] Switched image loading for static & media files to filesystem --- mail_editor/process.py | 47 ++++++++++++++++++++++++++++++------- tests/media/logo.jpg | Bin 0 -> 4393 bytes tests/settings.py | 6 +++++ tests/static/logo.png | Bin 0 -> 35486 bytes tests/test_process_html.py | 23 +++++++++++++++--- 5 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 tests/media/logo.jpg create mode 100644 tests/static/logo.png diff --git a/mail_editor/process.py b/mail_editor/process.py index 6a6f990..4bee3f8 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -1,17 +1,47 @@ import hashlib +import os +from mimetypes import guess_type +import requests from lxml import etree +from django.conf import settings + + +def read_image_file(path): + with open(path, "rb") as f: + content = f.read() + content_type, _encoding = guess_type(path) + return content, content_type + +def load_image(url, base_url): + # TODO support data urls? + # TODO handle errors + # TODO use storage backend API instead of manually building paths etc + # TODO ~~support more storage backends~~ + + if url.startswith(settings.STATIC_URL): + url = url[len(settings.STATIC_URL):] + url = os.path.join(settings.STATIC_ROOT, url) + + content, content_type = read_image_file(url) + + elif url.startswith(settings.MEDIA_URL): + url = url[len(settings.MEDIA_URL):] + url = os.path.join(settings.MEDIA_ROOT, url) + + content, content_type = read_image_file(url) + else: + url = make_url_absolute(base_url) + # TODO check domains? + r = requests.get(url) + content_type = r.headers["Content-Type"] + content = r.content -def load_image(url): - import requests - # TODO check domains? - # TODO check data urls - r = requests.get(url) - content_type = r.headers["Content-Type"] if not content_type.startswith("image/"): + # TODO lets not blow-up raise Exception("not an image file") - return r.content, content_type + return content, content_type def make_url_absolute(url, base_url=""): @@ -47,8 +77,7 @@ def process_html(html, base_url="", extract_attachments=True, fix_links=True): if url in url_cid_cache: cid = url_cid_cache[url] else: - url = make_url_absolute(url, base_url) - content, content_type = load_image(url) + content, content_type = load_image(url, base_url) cid = cid_for_bytes(content) image_attachments[cid] = (content, content_type) img.set("src", f"cid:{cid}") diff --git a/tests/media/logo.jpg b/tests/media/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..93f8827beb3ff9309c9552fa9f9bdb0ec27437f0 GIT binary patch literal 4393 zcmb7GXIN8d(>{O{sYZI&P^1J9NI;OHNa#gK0)!TpDn+Vv!9^DY5iryxR6%-Iibw!4 zfOMpHqy(ghAPOigZ`|VU^}XNs3zEc-h#`aa`aBgD>z43JQxsA;O}vmjnf+RHbBL zipt8$LXzs5>Q`<+m6R1J=;`U17@2rjSa=jg1Vt48-$Hr~fari`po@wE3{Zk7s6Z5? zc7Tr@ASERgIm(|xLrXzTd4`UP{8i^1KtV-GemP6aNJ;g_9~6{SXQ*jFwC6AS$X+-3 z#V?|!ja5hvrl5KM`49(KUIXQeWn4=DX}`X3q<4eVFuYs(cc
-ZW@5%3E8}E<38h=n_a5e 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 literal 0 HcmV?d00001 diff --git a/tests/settings.py b/tests/settings.py index 7e8e30c..1acaf16 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -72,3 +72,9 @@ SILENCED_SYSTEM_CHECKS = [ "models.W042", # AutoField warning not relevant for tests ] + + +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(os.path.dirname(__file__), "static") +MEDIA_URL = "/media/" +MEDIA_ROOT = os.path.join(os.path.dirname(__file__), "media") diff --git a/tests/static/logo.png b/tests/static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..115df0314261a799e8611590576911174135bb4d GIT binary patch literal 35486 zcmafZWmFwau!pP0IIBvgxbI7=)bEA0q)=Zp@sbn0Du5wB}6s6jm`q# zO*EF%ejjz=--}7#ZKBp@+jFgYbH7^8=PK?D#-H_|tDrf>0*EFT!{ePNOz`2!`70TSIoYS=*-FA%lJx>WO!nw-4^G;J9te=Ou zthrSN>;PVU-h992w4R`Aa;G4^eAm*goMVoyozKqO61TJ%v_uZvtu5}aU%xazoG z(|_!~E6!&|e{$9oxTQIff(|J^`=^;y5>HM~)23ijIo`W#-U4Diy`NGpb$&}&zvc#A z_mq8JPNh$2vd}}WoT|3**#0=SJa)0FOf7wP*uT_&8=24dAGuJlh^u35)Zd-He9PaT zCjXe`H|EoRUIOk+&wRMo+MS-9G!-%3z{~5mwGIV!zK@Y(d}!$FpOR{CZC!B$da-<5 z1#LdhwT(-{N3u5NS8T&XN?Jq7{lBlhCZ{AgIXU@$cMJ4BzQu0k#t7@lA2I-O%q%!A zOZ<101C4<>ZGsD+9@?Ut=A=f*S2(;e`xNA6$O1+06##^YNf`&rmf@#;6B*shnmu4U6esbmE_vLBE z?N!AQ%A*|FrxpL4qHDpG4tAs*NHzMv{87%|7qk~dSgY4Uc>C`^(3l6^{(b7(Ua`NI z!BOZ8eXz7pdj64JiK}K&`XkXvRT}&Ww<=L=Z;m!n-xKBY4pOJ>sexvWim(sqxr2v`mL_$}Y2@jA-V7^R zEMgR_8M3obk(mYejFv=RC4`0Srs*xAw~tz1e~JiAC)XK@!QO04@rieYK8$+7@7;2T zZDPJ4gc}Q`5eL$%6=)C|)|*^Y&{@TN+6okdFl{lK4Hzh*vmkNqKmbKy392I2plyz1 z9Z+SOU8^dU2X0;`7($vtoBE~cYM0gdGa`TYf_rO# zdFb^z$Ora`yaW0(FDsleSq;p{g6ZZ-y?v7bV?f*i-7?{2Gt%AvdefwATZvQYuuq9s z$q{(ctZF^E_hjS4EQCom{&vQ5AuH3DV6+}O!QJ)UW}VCQ?SR`owkJF1`)8Bojy3P| z(?DK8>*=Vfz{}>V&i!@-A8)d8vV-^Dm$^n z1)mQN4-SCoMvYWX7mQA$0P!9y_Lh$OTE45c5~3ajk%Hqap$++htR;P%#cv2^F8Ksa zP)H_X^xjBaU?5h@<)4^KIvR7UGf#yz%m}axJppNU{4AKBpqUeq>NhQ=tvsbz7@U)$ zsCOZe-cWE3WAKhSRusgBYg4%Ap0|Fbd0!+JyW6CZYZ4@8`z4Uw&#Pd_Q!PK zx%>8Zi1bx|Ly8P$H$&xDkUtuMn`RPj-pj|~JeI1w;uv5IVw-iJKosA^RMuKriO)izwy*&&F0l&^OkB*(Qiw^huQP zxsn=+;&Y+e_BnEI@2THbK9m)uX;`66TiibM$*@=VC<$BGE+Omu-){nx0CNtpxw%<> zZuE0Ew^@%z7WdU=?A%-A8G1VST#m*W&VhKcisPg7jNnKO{_tVCi9y1$P7YQsRN(NbMn(Y9L6=!h-lgSTccIW#|`ZnJN@CGng)@vqvJ zByaSLB&m#1m@+bh&Dgrz@nRCX!;DvQTDdr}k+J5$F8b`@BM5A2IS9>%W+GN(zN>}^ zlbcZ@))a8-$Y4+xpeqG=hO+^1jvb9rZ%)TYjb9{vV-!k@WAcZKKajv0n|KJ4DCI{i z!s1xKUQpclMU&ZdTuKk2xzK94iuSr&1&EMHO7L5^IP6M$cUVyuy zSM=8m)usIYY6;uue@S*rfe*Zajb5y!kw<6u{m4i3e22Y*8ll#>6ij}sFIG4|<<+?O z%JvFk4Ectw($VDbe-(8_&OWR{#sBI0mL5a^43jRINBh|ofd5jqZ)V|7#In_i=;^a` z(Z2hnaPVcRp~fIb>_fDgF{GORdQ^iYi=6kzdr2t<8qlkdOmKx&BlFuLv zD=kkf{5T-g*rtIx0=6731ak@_=RXbxV(00LfDB^nn8+G^Ha1SgF2l^D9=n7|IR4qG zqy&IyK*v{`dI#2aezcz*RRp9+Fw|kX2w$8jK*G{i7;P-ld|$Nr7|J9*gg}2+qdwvV zVr3_K7hUudp*{PxDMfA90IrzG&n20t@?f>gfyDbeLc|~qApHKlbdX~I*?+u+>`_{Y z?f_DC^f8Gxd_%RzLbn&POtzR=nnsvh8Qs?zA^BlpyM)+J7GRtVK2TCbiehanbd|n} zKoFJ*^CsY%0bjpSLZ3ip9 z(dh-sI|Y3Dqy}WebHAC@26$4Ee&~3^qvk{ArU`m2ldDU}8P;}DKtDMR)Xy%2H6NS+ zwZ>UDJIel+n7HJuSYpq8^V>02_nNRZMN1FZg^qo^%JSqRyh+1#Auh?R?WB>!z@NR*$7d}EO8n2Aer zBvznVnUBXSx}DjC9w*QRQJKeL$302=spX9;C0X(di@HcNc7fz;*rO{(s|_}-y)UfS z+H;hX8f zO@dSBpVL2>kH|Wp19Df7^g%nxYLIpdIGco{WGJ|@a(PdnoL&-U_S_1ZF8quvUzIx1 zkUe7iI(m`y0sql~H@YFmrubu%*DT#O24&0na4d7%lcoX7F9wCOMivLaKVI9hDWz1D zv}qFS{(|2oBCY*~S+MdVpI&8GB$=bD71}XCQ78a zY?QQZwYYJ2h1!Tx^697J?UznvA=GFK#d3pH2|J=EQXtnR^Bo+bbiO@pr4@vIykOMw z3+>cn(A-_4@M*0bj*A|*m1k+v>EuFSEoN&dhVNQMBE(dTv(!Y%P;(kWEMPVHQ_Y(C zgNlI#F5rytC_guDbCvg-6SfEaP*nVHO5k|*k?nc}3n2|9>_buZBmqtgP|3zS{%frW zwWKTN+Q_9;dg#Vc8pCk*`Ii^V&ZrC7h#40@#Fvt;kgJ6jFy9BjsxvPn-*HSzs||=? zUB(45_+#eNz{FjL>o_>X+wQ}wWKg%la#^RKEcI<1&3&So;wR5mA{FfUuen)XR&3H+ zSRR&$Zv=$l>=Mu}tqb#uo0_4uX6c@#ij=)Snb&7&=jbkf8ZZLnJfajEIEvLjY?x;nxvHw(dQ>lYiq=PXm+Ki{u2cLz$=-951b1@W)9}kSU7e!)Le{I zK2aozGaOuU8tRQ79~GfX0-Zacfpcz(G5wk_bI(!{hK>#ef{kw0s65hT!Z(hRsOG5C ze@p$IBK!Kgo&TXAq1+Iflm-C{mx)dN18<{AS`G7s`m&Gh&@@(_aky1m{rY`5CCSL{ zq_@3(>VJ42GKmo+1J}b9gN{w9^b)BA$*|F!k@9y}aG!3}kwGf0o&-sIo7${0U`}}C z`zA~FN-3AS3*QVg9r4C3a_aH=s@sez?c$VfM_8Q%l`Jl}S-zTNQF>L^N*n1B z%J^849XlI{hR}R>$0gu{Y~_zsw7-#EFqIACVq4XqQCasOTuzraupfUni4CB7p6tSc zt7#;Ers0!Y>)|Zr{j!VB=5}jGU^b0~J4SDt0-f2+h_MO2Z!PwHe1HBRvTeTm6 z?n>B!u908Iu$!wyx+nItqSZC-6;KqLO_vTN1n#ft4P8ECiBy^`Gh$M!c9AyQ0AjP- zreBdk!#phjwVD%Lap#G>v^bCGoOTCmmf_APlKveFIGO*%jYVCj?TMBtp;i&VP3!n1 zq7fvjRcDe%)Wh0d5#^@wOzzV%lKA>iFJ&|o{(exj!=*dFQWgBQ7aGaea-S|Incj4W zUTzC>TOGZ?wObwNUYGolL;r$@yD7tVGr6;tv>1Jbvs~3OnaW=($%$nGza5&d{?eOP zKki>zM?aX@jFJ5b_=X<%dEofAl}++e>xQoMyL)b__Hvf{XnATN;~GS>UI ziA1%_ZI;;(0$O%2Hod?oJV^2bbPW#gdjZja;R@ZresW-kC*szAG){x*$^^Kz`HC%P zkyhoQcwr#4Fp^^1%6Z%eFsKI&evr;>u(h@26xlM|*7719+OH}pd1BRP2zT^_-X7EK zKMFgtUT21Dj}*AqKvXdhgDoVFmXl`#9^qX{?w)>3nYR) zR%K`j9NeYYXLFg{WT;t6OcZPDrNF-kCA5xbUH(mwnnB%ktwH6;e}frFE%gkODG)JI z8WrjS;t@oDmr%TvNci^q8$A>T3(uqL84i`P5F^(3p{@V-N&^FaWK7gOKN}zK{VF}p ziDQ#WlWzjNUEcvVy71SPIxs@(gCGvBr4A@vUD`Uwobqy&W6^NKr&X|mvX_a9SY>oC z3+0Y)*(JND_=}LcJA#aaE6m>I{0z{S@}-y;6E6kiIY=8f*5)xy*6DvHf6lguZhEKb zE9f6Jj2nfVj1p`y${s>REYZ&CJR1>4wTR_|EIkb!n9ylY8F(PyIhFnfyq$(3`FzMh z@3RXmN1PK$n>e)NjEswE)*G%Y>{fjYbQa+KP2y&EHr7f<9J@AIj^f+RL*MOr0&}t6 zLcSHJ*n5=T0@CQ6+oDf#1f~NYPTFO?h?k{92jLJ$*DFpPHAZ|baSDTykyF@_u4cC6 z7K;^}PX$hXX z)srl$&C#>`4n5_{l)S&teSvcnEeP*e^K8EIOo3TRVVcrglCG8|!~Z!`j06!MWd2^t z%j99;;r=(>|7`c$M#(`R3xm|3<3q$TmY5L|V`po(R(f6@mA-MS+p!Cd;HxBF!m}VM>ch1F)cGV|Y+b=sTdWy%-h)^{H}k^;M^d10oaVw|Ck9x0QM_Qg*PATNt8#(LiqXpu#cc z)YHJ4ixz!wcaFQ2IS2JavK#OM4vUbc@ipy?I zryuZt`hA)m$7^ok5ENO-Aqc@6ocl!U#*~o>>B~0y=9nbev3ZoDYnHetaz0+`WI$=K zZ&dW+>IG1SOkDD2c0My?bcOou3nRL|-N_=dr%2vyUUmn%{>h#jT>#s@g2LHI=SPU$ z`{1YBbT2n+_v1r4`{xcoHJ*1OFeaP>L-;Y#1(t({?7#51}}rP#MBYsj3Lhb7q;AhV`eoglVIqSmJ-v5KfCqoROKfVd&kBjaJ+OP zwknLCkx&wP&62gmJvRJsL#02>e+`5Jy%{P`N%2GsQptktty$XXN=^Lz*~z?L1J1r9 zsy_crDwrwtdlz*H1TUR_^EuN}1{flwy~Nc~gG>nYYz z3nh<|w~^p6E)N$T!KfTwE*yp)l+{93oNUo^H42JM&46^_P@?rFu5U>b+;VZ$dj}^P zbYslI)KN9yn&3zXP;k8|M1$Xz!U6WRW5{E%>)nMsWp0dp z7G<3$YI`)UXo5tZ)YVPTnIZMpUq6|5tly^Q3EwhV+4Pw3c&4O2kA`{#55@O>sEZl< z7`+3;xLjW`K501<%k^fl*klPh|AP9?q|KT%!eWVw_?1Qlk2hSA-rG%BgSm!4gPK3{ zDy;yRfoB;u0I?I36uY@!V1GX%ynrj32W}##@{aGrQ_F^5>=8kn61pMo-(5O3eEuRx zA&R_2v8>R?VE&LAGK+N8CPpm{XpdHcG`Z~;*{Kmh8T*j7q>*U#a2xuTqT4#(R3#Ks z*(pO0`2dRvL-OHeI){?sy2D!}yC<)vbyirm9`adTR3W_})nFY4t(3cOh@i>zVZpu0 zh+(^cjy3z0D)ExZI|Iv#O1ItJH7U);3kIR!g2DsoNX2!2Dr3MlP^K1$VAyG!jo^VL zboL`DF7lkd)$)`7i{n=`fKYk&%PA9%ua#=|S71hsTh*P2W|~!75zO|FVe&FQHd|?; zmr-$O5?BU7CP=+lF6q!WXOQJNgo4O}A)_rN3Fiw-E<) zX5>TTYn)uBvO`_?@xmacc8-*YHbo1W5qX4_ffQjFl0Fl)-x(jT zt}L2JWeIHPyn0ZN`T(enkED+?30NMaS&1L9i2u2X3s_tLIQ|0XTfY&OROd8c ztgLy;hg4;8Loil-QHtEw4EV0qQr)zKj2HL5d?6e#%>V9W9I}Z|@B_w%$l1wwY>SBR z9UXvCD_HN0Qq#Y>emgbLus4Yedcf2~@9NpkS<8YWjA1w@j+f>z@$M642T<^|rjJ1( zR9OKfyiyi4GrZGmTZaE5OFK0dF#4Xv+Vj0$Bm0U1xZ`rkZZd0Q(780sc)@ydG%1~t z0rQ!e6w@!25Iv+3Pj+5j;_3-;{_zj_DmgvFs`60~pX$ESg%6h%bqJCJm5R(T`(WTJ zpv^76Xu9e&7{FNafIZ!qxU{+c3KD32)h936jyvQ^p3>F1hU3t;(GSZIpY#<}>`Yk^ zps94p6RohG+mo{NFCA+dCHhvCe^gXg>3(YRNd*{8#+!7r!$oFlpBcY+%$M$_#iW`H z6De(YF&ODTVg34j+ns$269q{J*@=YPXJ9V`3Bwk8QPZ8@W-9D){}=c820^1uwj74i zf+B$au0i-Aqm}ulaHapdS@~xNGx~F!)OKy1oysN`w9kX9BV-iE@X&E)1~5GF24Ysn zN9;JE{XtnLGhc5mT3up9pVjD!`31{*?zhBQImd+|Y-AmtyK4x4_pqmj%8aWDkI~`B+8ils7bnaLva`^hFt2nT-t7V zm81Phk(L@^iouQ1oKp$isfB{A#B;qX;Y;ErA7aOJwEFd3Thi-26h^;<<795s;$l`- zL-%lXjD7lH>{cGA(^4AUul5Giw~da`9F~S3^Difcy*%2yXSxQma8T6rji*4VZ|#MO zY}F%6C@%szv!lynHo(Pj>m0rew zP!mzCc8Mtu+1+t#*dqTH4g2RUj;-eSW z((_#tlG%R@PlhO6%Z7_7D*9wFrVJ}%g6%-nmUaXRgN+240@A^guPu*^^-l%bY?hO( zVAGX*0^vP81yYxu8R&4SO-6V8()e~%b?FJOBFCYzI`u1=)RlAeVb9^ZA`v7#66>TPsuA5(IX2VCPV7Dhj zd#zs3P0UuXZ`x6?sqoCr7L3_kj?Oc9-ry7nu8{@WC49fX~dr6FP1S=p*shOBRFrC+un#VOS@&U=js z;ES}Ln{oS=@`H_gEu6jrz?GJ}%kT&>uvqW7$y_PzU)SOpZN-v!u=Xu3vg3E>1l0mD(elx}lT!GR=o(sf~ng8rwB-^L|L$fCpVW{jDk)**jG4~UA`0JTD z>)2a8i|jHK8ka%PPJ}JKvJD6H;4d&hRQ9$yM>meq;`7#`~8PQuswa4BXHj~8UNKC0pp=*F`~w1A`DN-(b~^by{l7%;udM; z{Fq--?gK}?|8gibF%4fv-eqVIb!{0t;!o_zXL6(~O9tnRRaIgG?*TBf4(3|DAre86 z2el3cj5wpHmHUzVAji5U$T08!Z|5l>u)tfG-vY)aIx8AEn3v zuWM?=BAlwpQV=P`JNi)UJ-5ZYB-rf!M3nnRyTJb|Yl@G$IxbEsLGHO^JL#{KB7}2} zwr{Qa94hXOxCPq2Xuj*D1W6gySfR5WnrGkPJS9lIqh;)XJD%xs*2~|fwUDNTykE6c z%Os#eqvdeBNz@0H5Nhsch!l|ZnVaTRj))WppHgQUGw@o0^C8P7rEgZA3+5S|XB3k} zF5FVEA4qn1yc+PeEi*N>{~-}_+cqv7D%KJHdbwaa-%$>z&GwvnMOiP(* zcm@44+Vm@+&&`uay5<}*@{h%m)^}%|_HK%F@ti7PyhkFJIkSFeHsZ@jO=6O~<&5=M zS+CC0P)RN_NLB(0Qi^)*OIrYxbzSq(GpwBOT_k8IvRqPpyEaF?X3EYcb{^Zc`$)eH z`~f>$fNhXVJ#Dy9F@OT0G_01hj{PTxm@Fe|ltd=iH>y%pf4F&gr(T6;Kdlv{$#|XM zRdKSRvjV7JEB4JQAqk_`m zL{3is3uoDEJShf_#$tMzs%1>ZV=_)5CT8|g@&;wyI>2Jru7vvuC1oXam)Xgfz4i^XihY{w4j30l%)|S8f5Lwv$K! zbK;bOVp_z>m!%Qay>}|J^8@4qD}61d-?ofR_Cx4T9IH#LXrT3LkXm3dkO>X}k(%9L zhz{f%8d}kdW>mCs1suogVHZ{N)4TGL>B-D`Nf6m!aUxCL2T$&G56d%@MbcfsKfx6Y z$(Y3P@SQ$UR|FIwyZRP`2dB|6RLjKjRc?aCZQ|DGEFiq%%O>uh9pHe`jaJuj(v`IP z5~13g)E-|CFu=^weAL)=W?w68Ez7me7xz{VECu_Hl|pqfR9!HKzO9@$e08X}efkgh z!K@;-i;{y#W6;No-=IfJ;1e^cEJVjB#0#&Y|`ySsWHcM=J@Yfp@zEOP7Z_ z-pjO>!ANhPVVD1Pz{7jxY4mt$w!tRHXe+@-!lEhXWV>HRv4&RsCLM0QGB%al>&o~7$>-mb zR453cwAeWQpJfG|C|z>74sgu_9Oq_6nO0QD*nW-MHAByrgk;`(#5|=q(ggn&%^f;0PwN`eF68pAmLeepGi-w=@t zwN3i}mJkU4m7R?9ZCeCn%6WWsCA3OtHQwl4(o?0HhF4A0vn{W%o)7JJ@oE9?CBu#W zli0{1v8RvJ2k1ev#iMYUKA*vvMUEqXuDN@Yu4)Ni8#Q_?Az$|!92^<|{c6fyEn1aI znv4&Kgj^44SPI0RRJ5UgzWY$+sSNmf4$5bIJ*wb>Z>E7)MVO4LOsyW0(g!4!`MqcV2!Id9 z`YIn)7b@uU46qGFfsfo4eF2MDM{!#{P#tN_#%uvi3tuyQM&X)2I?`Zr<{C@nj_ug- zp}Fxz!*GA;dJk=|vXTfEDdxiO0X`C*U<*ft$I62f)%S~nhbC;L_ZeGB1mH2HW$>ZZ z799TY!F-T_v2NaLHi>4;dY3w!d)*~Gkk9&WrRNiY>?Rp}XE+oYkMqLB?0^mmJ(d~# zce+dj&xN(bv7}81=?MW?121p7yO}qQn>VW=8-LTt+G7?NMU~P?6ahAhuLaZl52|vB zS(Ua6sl;eJADH#;B_SESJ6sazIP2y|M6s7GIJbp)eO|-e!8U!2U>fI&mRx)}2f%X~ zd?Z9X)}; zFhG;}N25GC+7vf5hN8&NI*;;4`=X8gH<+H6ZP$N{0KGJndGO6tdG}_U)pYzXo%7J> zp@eTW4sJ|Rrvt#IqKOEE;&z#61K2_JN?*PplswAK3+aAPkf0-kIFp9zGAZN|m`EGj zOyJtL`C|hbs3S|Pbg>4_2c@eAp1x1uezAZfI(3t-9re z>i5!6r~v)@-;s=tgN7zL$mMMncguoWxP%DpbHneVt_|&@)=ni=JW~H5Jk;9D53am~ z&cJQ}3g@(wCXrr@6pK!{?2n>SDpvnr&{ahB=?7TsbwMT81Ol$(+=mGP{h4ak6SR=4 z-CKZ+mww(he>%Dd!ABq*#Na@H6clT0SJ8w7QdpP~N=X9)OwYQ8w{H=QpF6V!;X~#* zz&;T`1n5B@)_K{5#U4TkTTU>_xP7wL*UN=bNp?3Des~crVyZMa9}e@ycE{#&3OJcY ziLS!${G8u#XrK%oNBy83VodyVlqklQlW4MtikW^X<)|=Q9TMt{*H5@gO_z zHBP4A#$+{q1lbcPI?n3QV$Z7n?ZK}e-m8CAn4$T;If-dX3pCce5wnrIvQ+bsZgGHvu|9*J-ue$H#)mJG3aD&Nd-Nrp9g4tR=+%nUL+z|McRu48~fwjlYhhE_6A1OI{wLgThJTeDHY z4DJKRGw)~8kU-yNrEwLmc2#Q{4<2mu983_NjQ_Yz&poq%^`-jPg&_L5Dh(&~ci5|) z%8>SLJGCn@W#WL{$*hzhzv=YW#u>og1u}Fb`c^I}$fFF}LOK|#*jg%2^^zI#g(nNT zxTOmb)FA+%cYq${hE7~9G}G;^hxAWBji)`)o1#Zm-dn#-FD4Yk_jN78AjElAWV8Hf zo1P>X=lSU6+3NnQ)Uj*!Ge1?D)0U+>g&gX3WFv-t`_bH1N1eyd&C3(}x#iKn0u|%{ zm|eEfppkIBy5vnd#bufgPB*t&P*b(M=qndprU+PvUlUP$9vVUvv|5pdf5iK}icf&A z_oYOl&YTNc4jp)OV2wsuIKs-tdnej}s^z`r$H%q?n1f4nf&r!Cz`!|R1`j4vzB$^? zASs|3(N6oo6v4j(h2I}(o^}G%YhG;Shi)_r{MUUc&JUcj)Gd$c114&E^LrTQ%=>FH z@I3H`Q99AFp~*SURSpL-$9lRB<^OGce0YSYf7vZ2`kmL+!9KGysoMUtZS<<;FWYe; zS?L|TDNpVE?`wWU^EnkcXegHf-ugZgaR|wA6S)L}PsOtL*x$l*Z?#W?($f_r;eSnqq0pSo2p@}SrFef7( z2Gc_W;DEx2wPkO(t0jimdB17hWif?Mjs_RW|HX%}&vl0ia1;p^2pV9pl8Mhf|Z zoz{GS@Vf#kev>64?|n&gSwIg?M&*|wFc~F9J1y?3hZ*Oy_Wy#^E5Ifx^t!do%wih1 zmGXr*?nf@cv-I0eM8ft=Eq1BzTp8*;tuXWHxJHHEo|~ejFW2xa`SRGkLOWM2W&T^9xx<7GZU(Q*zO@x z)IiHD-%-NK2uEPgtE;<61R5`z0MyV*YgYpefY! z7R;0ly{e4J$>4g^iY0Mwqz%p{e1<87iHXCp?RBvt-osLD=(dC$$+g3S=GD~hWNeT@ zxRk#7sEH?VWR_-tU&=<-Jae;j{MfnkuCrqY6_Y(oB1>lOmzu4vceIwn$J>l-ZYyT7 zsBJ*u*AJ{tO#=t1+>nbjgy!<9Dlmmi8CYT7BRnf3w#WR6>M`4!TXwAE^_J+$i387U zua}sO`NUN9o@}_foQ^Zl-gm{972uDpMv#aBN{1wUFhdtd1aQCb+;HyM$f;qWjbAdJ zX6{s9oL1Gd>~T zXG&RbTZsaOahT0Tj68WKo+SCesLgB=HSB1lwE0;#+-W-PbFzk`w*xy?vNQDZ12{L| zAPt-wIq2ORl-M^#1_^`|qk;;yC`CdECV9M$q)@GLNSlGX(D-&rwX-2ky__7E{qWM? zUBS}cGs>LGJ4p}hCJn!Stq~JBz4RuNoZ(ND*NNqJkfdprsp#w12$<*YY3R~o1uV-N z2%fV>p4`J5fCD*Mh`Y3kSFlesiC0g-0nKac{`z21GmRQ9NYi8xuv^;1rHpowG4H?L z;>CNlJ}%rU8ofC1JN{U8mxT5CM7VIs(MQ6BTe{hBJv`X*C{E|rn$KSdONtRx*ZUd2xbxUfK`@+i-TpL{inhtlbMB6<$Rw|%}i z2&Ze`dQH((sx=`93hv%Y<2*O9Cz%z51V^K45cj>!gfpm;DNik{mKmdpE2Arf&)fy|vz3f?fHOv`G!5&ft1G$Ti3p8JVKiUnJ?=hLRPug)p0 z2an4QGBlGZ%CJb_l_#?nYt+cq&wP`$M0hZVEzJQ+0M@OvP@D_&4>N@xniI{UmqQ09 zf?HH>2N=7lqmQ7VfPXa~MCCi#Nz^vlM{QwnAb}5vPsCXM8Rq3R{I%>+%DD3*$wtB; zGk#7vE=eZLq*87<{~EI7f8%|C2vQEUWX+)+98G#s-2sasrVo$X0zmBu26c5#5}AO0 zlblACtH!d{PmNevu9Q(`tSoXnX$`%+avgzvE&IgW8@-G=WOiuvXfV$;$hYh~9SQHW zLb&VJ4Kt4*LGfn&F-&gNXB;23yrOFqISeW|>V3)7vz$$R1v`VNN#W6E8YKWJ1+IE% zp?9&>cM`6F^#b=mFMMKfiD1=(x0d7sjuzG6yN`I@0z`5y_OIqR+fwvvwX^BZQC#+_ ztuVX8y1oLaV<&cH(h`vpME2Bn{e%2#qMJeHi(h+d@Sj#TGgMm&!JTj$&`!#40Bt4x zazZT5fN!I46;;jUV*vT^BMuy^l0ad4=QQ8ue}M~2_$vp4fPhh|MH%4B z_oe)GGJ5Vxjp{bbuW{&`led?BBs)GHImiRpZYUy1urM7cBHm}3Dmy}?#(pM+2Ssm&OR9K>CJb6;^|7bH#Rm zY8+cZ5#!Do;QQCj>`!-bh1*m1s;OOSmg^f5hYKp@=)g}*M$TWJvo)E%O{!^Ztx|pR zU5tzggb;5LGqnB&rEpGvi{#naTMxOj1hs|HJFn^g>P#2@^53ic)Ge4k{;_@hy??S# zEumg>4zBjDZVva8aGUt+@nCi2AzSSyHdL%N*e=qARslhe3G(d(9cls}q)^tk4!q}21`PN6c2gxzr%u{D6P?9?XmfmA&D5g-sx1c zD8Yx*(V6folt<-Jj76*3RwS+cuboNnP!2-7%7c_q1OJ?TVnDu)=VMNr{psy+SzNJX zM;0#c>9QT9jXi#*K9CZsne9A;8vSEx2X6n&ZS{W>=NSz-s1n#<8mP0*?uCM19rT)R zC!}(7*9#$os8c~V@IN9?=6W+qJ@klcepyPZ+TjS!TR8lM27{29o+)1nSGU}0#L+L| z%J!e~9-o1b6;=3S8!_@Qf{FY3xm+40%tQ%XV)z<^n{XShGM%6D<}i|?jDq;jfdZ`J zE*XsAfjy~}^M;VeN}S{NDuVO5c`zFVSz`)z5~wEt+bW)`zhKtfDPPG^^%e@}2d+O~ z?kPLeg}Pbwga#dRO?naðXxi4McZ&6wV#^0*lc^8=FtQ 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 Date: Wed, 7 Feb 2024 11:31:08 +0100 Subject: [PATCH 04/10] Added support for fixing url in link elements --- mail_editor/process.py | 9 ++++++--- tests/test_process_html.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/mail_editor/process.py b/mail_editor/process.py index 4bee3f8..5c43682 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -1,4 +1,5 @@ import hashlib +import itertools import os from mimetypes import guess_type @@ -14,6 +15,7 @@ def read_image_file(path): content_type, _encoding = guess_type(path) return content, content_type + def load_image(url, base_url): # TODO support data urls? # TODO handle errors @@ -83,11 +85,12 @@ def process_html(html, base_url="", extract_attachments=True, fix_links=True): img.set("src", f"cid:{cid}") if fix_links: - for a in root.iterfind(".//a"): - url = a.get("href") + # TODO figure out how lxml/xpath OR operator works + for href_elem in itertools.chain(root.iterfind(".//a"), root.iterfind(".//link")): + url = href_elem.get("href") if not url: continue - a.set("href", make_url_absolute(url, base_url)) + href_elem.set("href", make_url_absolute(url, base_url)) result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html") attachments = [(cid, content, ct) for cid, (content, ct) in image_attachments.items()] diff --git a/tests/test_process_html.py b/tests/test_process_html.py index a8638a8..4778557 100644 --- a/tests/test_process_html.py +++ b/tests/test_process_html.py @@ -22,7 +22,7 @@ def test_process_html_extract_images(self, m1, m2): self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, [("MY_CID", b"abc", "image/jpg")]) - def test_process_html_fix_urls(self): + def test_process_html_fix_anchor_urls(self): html = '' result, objects = process_html(html, base_url="https://example.com") @@ -31,6 +31,15 @@ def test_process_html_fix_urls(self): self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, []) + def test_process_html_fix_link_urls(self): + html = '' + result, objects = process_html(html, base_url="https://example.com") + + expected_html = f'' + + self.assertEqual(result.rstrip(), expected_html) + self.assertEqual(objects, []) + class ProcessHelpersTestCase(TestCase): def test_make_url_absolute(self): From 336c788815d0b511c5f9251b7bd25387cc1bb2b4 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Wed, 7 Feb 2024 12:38:30 +0100 Subject: [PATCH 05/10] Replaced Node.js based CSS inliner with Python package 'css_inliner', removed related files, settings etc --- README.rst | 34 +++++----------------- bin/inject-inline-styles.js | 23 --------------- mail_editor/models.py | 25 +--------------- mail_editor/node.py | 23 --------------- mail_editor/process.py | 57 +++++++++++++++++++++++++++++++------ mail_editor/settings.py | 29 ------------------- setup.py | 5 +++- tests/test_process_html.py | 21 ++++++++++---- tests/test_send.py | 4 +-- 9 files changed, 78 insertions(+), 143 deletions(-) delete mode 100644 bin/inject-inline-styles.js delete mode 100644 mail_editor/node.py diff --git a/README.rst b/README.rst index f05e794..73ebd0a 100644 --- a/README.rst +++ b/README.rst @@ -31,10 +31,9 @@ and edit the actual templates in the front-end. Templates are validated on syntax *and* required/optional content. This project also aims to solve the HTML email template problems that you can have when -supporting all email clients. For this we will inject the css as inline styles. -We do this using a node project calles inline-css. This was also used by +supporting all email clients. This was also used by `foundation for email`_. Foundation for email is a good way to build your initial email -template on development mode. It will generate a separete html file and css file. +template on development mode. It will generate a separate html file and css file. For e-mail sending and logging, we recommend using a solution such as `Django Yubin`_. @@ -51,17 +50,15 @@ Warning This project is currently in development and not stable. -Used NPM packages ------------------ -This package uses NPM. This is to inject the inline styles and minify the HTML. -This packages are needed to make the email complient for all the email clients. +Installation +------------ -.. code:: shell +Install with pip: - npm install --save inline-css - npm install --save html-minifier +.. code:: shell + pip install mail_editor Add *'mail_editor'* to the installed apps: @@ -159,11 +156,6 @@ These settings are usefull to add: .. code:: python - # These settings are for inlining the css. - MAIL_EDITOR_PACKAGE_JSON_DIR = '/path/to/the/package.json' - MAIL_EDITOR_ADD_BIN_PATH = True or False - MAIL_EDITOR_BIN_PATH = 'path/to/virtualenv/bin' - # These settings make sure that CKEDITOR does not strip any html tags. like
CKEDITOR_CONFIGS = { 'mail_editor': { @@ -197,18 +189,6 @@ Install with pip: pip install mail_editor -Add *'mail_editor'* to the installed apps: - -.. code:: python - - # settings.py - - INSTALLED_APPS = [ - ... - 'mail_editor', - ... - ] - .. _Django Yubin: https://github.com/APSL/django-yubin .. _Sergei Maertens: https://github.com/sergei-maertens .. _langerak-gkv: https://github.com/sergei-maertens/langerak-gkv/blob/master/src/langerak_gkv/mailing/mail_template.py diff --git a/bin/inject-inline-styles.js b/bin/inject-inline-styles.js deleted file mode 100644 index 116ba50..0000000 --- a/bin/inject-inline-styles.js +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env node - -var fs = require('fs'); -var inlineCss = require('inline-css'); -var minify = require('html-minifier').minify; - - -var file_name = process.argv[2]; // 0 is node, 1 is the file -var html = fs.readFileSync(file_name, {encoding: 'utf-8'}); -var inline_options = { - applyStyleTags: false, - removeStyleTags: true, - preserveMediaQueries: true, - removeLinkTags: false, - applyWidthAttributes: true, - applyTableAttributes: true, - url: 'http://localhost:8000' -}; - -inlineCss(html, inline_options) -.then(function(html) { - process.stdout.write(minify(html, {collapseWhitespace: true, minifyCSS: true})); -}); diff --git a/mail_editor/models.py b/mail_editor/models.py index 4c86400..a18d6cb 100644 --- a/mail_editor/models.py +++ b/mail_editor/models.py @@ -1,10 +1,7 @@ import copy import logging import os -import subprocess from email.mime.image import MIMEImage -from tempfile import NamedTemporaryFile -from xml import etree from django.conf import settings as django_settings from django.core.exceptions import ValidationError @@ -12,7 +9,7 @@ from django.core.mail import EmailMultiAlternatives from django.db import models from django.db.models import Q -from django.template import Context, Template, loader +from django.template import Context, Template from django.utils.html import strip_tags from django.utils.module_loading import import_string from django.utils.safestring import mark_safe @@ -21,7 +18,6 @@ from .process import process_html from .settings import get_config, settings from .mail_template import validate_template -from .node import locate_package_json from .utils import variable_help_text logger = logging.getLogger(__name__) @@ -147,25 +143,6 @@ def render(self, context, subj_context=None): body_context.update({'content': partial_body}) body = template_function(self.base_template_path, body_context) - # inline styles - package_json = locate_package_json() - if package_json: - env = os.environ.copy() - if settings.ADD_BIN_PATH: - env['PATH'] = '{}:{}'.format(env['PATH'], settings.BIN_PATH) - - with NamedTemporaryFile() as temp_file: - temp_file.write(bytes(body, 'UTF-8')) - process = subprocess.Popen( - "inject-inline-styles.js {}".format(temp_file.name), shell=True, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - env=env, universal_newlines=True, cwd=os.path.dirname(package_json) - ) - - body, err = process.communicate() - if err: - raise Exception(err) - return tpl_subject.render(subj_ctx), mark_safe(body) def build_message(self, to_addresses, context, subj_context=None, txt=False, attachments=None, diff --git a/mail_editor/node.py b/mail_editor/node.py deleted file mode 100644 index 3f477da..0000000 --- a/mail_editor/node.py +++ /dev/null @@ -1,23 +0,0 @@ -import os - -from django.core.exceptions import ImproperlyConfigured - -from .settings import settings - - -def locate_package_json(): - """ - Find and return the location of package.json. - """ - if settings.PACKAGE_JSON_DIR: - if not os.path.exists(settings.PACKAGE_JSON_DIR): - raise ImproperlyConfigured( - "Could not locate 'package.json'. Set PACKAGE_JSON_DIR " - "to the directory that holds 'package.json'." - ) - path = os.path.join(settings.PACKAGE_JSON_DIR, 'package.json') - if not os.path.isfile(path): - raise ImproperlyConfigured("'package.json' does not exist, tried looking in %s" % path) - return path - - return None diff --git a/mail_editor/process.py b/mail_editor/process.py index 5c43682..43b4b25 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -3,6 +3,7 @@ import os from mimetypes import guess_type +import css_inline import requests from lxml import etree @@ -21,7 +22,7 @@ def load_image(url, base_url): # TODO handle errors # TODO use storage backend API instead of manually building paths etc # TODO ~~support more storage backends~~ - + # TODO refactor because this looks bad if url.startswith(settings.STATIC_URL): url = url[len(settings.STATIC_URL):] url = os.path.join(settings.STATIC_ROOT, url) @@ -62,7 +63,20 @@ def make_url_absolute(url, base_url=""): return base_url + url -def process_html(html, base_url="", extract_attachments=True, fix_links=True): +def html_inline_css(html): + inliner = css_inline.CSSInliner( + inline_style_tags=True, + keep_style_tags=False, + keep_link_tags=False, + load_remote_stylesheets=False, + extra_css=None, + base_url=f"file://{settings.STATIC_ROOT}", + ) + html = inliner.inline(html) + return html + + +def process_html(html, base_url="", extract_attachments=True, inline_css=True): parser = etree.HTMLParser() root = etree.fromstring(html, parser) @@ -84,18 +98,45 @@ def process_html(html, base_url="", extract_attachments=True, fix_links=True): image_attachments[cid] = (content, content_type) img.set("src", f"cid:{cid}") - if fix_links: - # TODO figure out how lxml/xpath OR operator works - for href_elem in itertools.chain(root.iterfind(".//a"), root.iterfind(".//link")): + fix_attribs = [ + (".//a", "href"), + ] + if not extract_attachments: + fix_attribs += [ + (".//img", "src"), + ] + if not inline_css: + fix_attribs += [ + (".//link", "href"), + ] + + for selector, attr in fix_attribs: + for elem in root.iterfind(selector): + url = elem.get(attr) + if not url: + continue + elem.set(attr, make_url_absolute(url, base_url)) + + if inline_css: + for href_elem in root.iterfind(".//link"): url = href_elem.get("href") if not url: continue - href_elem.set("href", make_url_absolute(url, base_url)) + if url.startswith(settings.STATIC_URL): + # this is needed so css-inliner's can use a file:// base_url + url = url[len(settings.STATIC_URL)] + href_elem.set("href", url) + else: + href_elem.set("href", make_url_absolute(url, base_url)) + + result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html").decode("utf8") + + if inline_css: + result = html_inline_css(result) - result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html") attachments = [(cid, content, ct) for cid, (content, ct) in image_attachments.items()] - return result.decode("utf8"), attachments + return result, attachments def cid_for_bytes(content): diff --git a/mail_editor/settings.py b/mail_editor/settings.py index af90947..ce15896 100644 --- a/mail_editor/settings.py +++ b/mail_editor/settings.py @@ -18,35 +18,6 @@ def TEMPLATES(self): warnings.warn('Setting MAIL_EDITOR_TEMPLATES is deprecated, please use MAIL_EDITOR_CONF.', DeprecationWarning) return tmp - @property - def PACKAGE_JSON_DIR(self): - # Location of folder holding "package.json". - tmp = getattr(django_settings, 'PACKAGE_JSON_DIR', None) - if tmp: - warnings.warn('Setting PACKAGE_JSON_DIR is deprecated, please use MAIL_EDITOR_PACKAGE_JSON_DIR.', DeprecationWarning) - else: - tmp = getattr(django_settings, 'MAIL_EDITOR_PACKAGE_JSON_DIR', None) - return tmp - - @property - def ADD_BIN_PATH(self): - # Location of folder holding "package.json". - tmp = getattr(django_settings, 'ADD_BIN_PATH', False) - if tmp: - warnings.warn('Setting ADD_BIN_PATH is deprecated, please use MAIL_EDITOR_ADD_BIN_PATH.', DeprecationWarning) - else: - tmp = getattr(django_settings, 'MAIL_EDITOR_ADD_BIN_PATH', False) - return tmp - - @property - def BIN_PATH(self): - tmp = getattr(django_settings, 'BIN_PATH', None) - if tmp: - warnings.warn('Setting BIN_PATH is deprecated, please use MAIL_EDITOR_BIN_PATH.', DeprecationWarning) - else: - tmp = getattr(django_settings, 'MAIL_EDITOR_BIN_PATH', False) - return tmp - @property def BASE_CONTEXT(self): return getattr(django_settings, 'MAIL_EDITOR_BASE_CONTEXT', {}) diff --git a/setup.py b/setup.py index f88ba3e..04cf732 100644 --- a/setup.py +++ b/setup.py @@ -15,9 +15,12 @@ 'Django>=1.8', 'django-choices', 'django-ckeditor', + 'requests', + 'lxml', + 'css_inline', ], include_package_data=True, - scripts=['bin/inject-inline-styles.js'], + scripts=[], packages=find_packages(exclude=["tests"]), # tests diff --git a/tests/test_process_html.py b/tests/test_process_html.py index 4778557..454198d 100644 --- a/tests/test_process_html.py +++ b/tests/test_process_html.py @@ -13,29 +13,38 @@ class ProcessTestCase(TestCase): @patch("mail_editor.process.cid_for_bytes", return_value="MY_CID") @patch("mail_editor.process.load_image", return_value=(b"abc", "image/jpg")) - def test_process_html_extract_images(self, m1, m2): + def test_extract_images(self, m1, m2): html = '

' result, objects = process_html(html) - expected_html = f'

' + expected_html = '

' self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, [("MY_CID", b"abc", "image/jpg")]) - def test_process_html_fix_anchor_urls(self): + def test_fix_anchor_urls(self): html = '

bar

' result, objects = process_html(html, base_url="https://example.com") - expected_html = f'

bar

' + expected_html = '

bar

' self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, []) - def test_process_html_fix_link_urls(self): + def test_fix_link_urls(self): html = '' result, objects = process_html(html, base_url="https://example.com") - expected_html = f'' + expected_html = '' + + self.assertEqual(result.rstrip(), expected_html) + self.assertEqual(objects, []) + + def test_inline_css(self): + html = '

foo

' + result, objects = process_html(html) + + expected_html = '

foo

' self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, []) diff --git a/tests/test_send.py b/tests/test_send.py index f097e7e..3b100d3 100644 --- a/tests/test_send.py +++ b/tests/test_send.py @@ -80,10 +80,10 @@ def test_send_email_processed_content(self, m0, m1): self.assertEqual(message.subject, _("Important message for 111")) self.assertNotIn('', message.body) - self.assertIn('', message.body) + self.assertIn('', message.body) - self.assertIn('', message.body) + self.assertIn(' Date: Thu, 8 Feb 2024 12:41:47 +0100 Subject: [PATCH 06/10] Bugfixed, refactored email post-processor and added support for multiple staticfiles locations during development --- mail_editor/models.py | 2 +- mail_editor/process.py | 231 +++++++++++++++++++++++-------------- mail_editor/views.py | 2 +- tests/settings.py | 10 ++ tests/static/css/style.css | 3 + tests/test_process_html.py | 25 ++-- 6 files changed, 173 insertions(+), 100 deletions(-) create mode 100644 tests/static/css/style.css diff --git a/mail_editor/models.py b/mail_editor/models.py index a18d6cb..fdabd68 100644 --- a/mail_editor/models.py +++ b/mail_editor/models.py @@ -157,7 +157,7 @@ def build_message(self, to_addresses, context, subj_context=None, txt=False, att """ subject, body = self.render(context, subj_context) - body, cid_attachments = process_html(body, base_url=settings.BASE_HOST) + body, cid_attachments = process_html(body, settings.BASE_HOST) text_body = txt or strip_tags(body) diff --git a/mail_editor/process.py b/mail_editor/process.py index 43b4b25..291c377 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -5,134 +5,104 @@ import css_inline import requests +from django.contrib.staticfiles import finders from lxml import etree from django.conf import settings -def read_image_file(path): - with open(path, "rb") as f: - content = f.read() - content_type, _encoding = guess_type(path) - return content, content_type - - -def load_image(url, base_url): - # TODO support data urls? - # TODO handle errors - # TODO use storage backend API instead of manually building paths etc - # TODO ~~support more storage backends~~ - # TODO refactor because this looks bad - if url.startswith(settings.STATIC_URL): - url = url[len(settings.STATIC_URL):] - url = os.path.join(settings.STATIC_ROOT, url) - - content, content_type = read_image_file(url) - - elif url.startswith(settings.MEDIA_URL): - url = url[len(settings.MEDIA_URL):] - url = os.path.join(settings.MEDIA_ROOT, url) - - content, content_type = read_image_file(url) - else: - url = make_url_absolute(base_url) - # TODO check domains? - r = requests.get(url) - content_type = r.headers["Content-Type"] - content = r.content - - if not content_type.startswith("image/"): - # TODO lets not blow-up - raise Exception("not an image file") - return content, content_type - +""" +notes: for attaching and inlining STATIC and MEDIA is hardcoded to FileSystemStorage +""" -def make_url_absolute(url, base_url=""): - """ - base_url: https://domain - """ - if not url: - if base_url: - return base_url - else: - return "/" - if "://" in url: - return url - if url[0] != "/": - url = f"/{url}" - return base_url + url +FILE_ROOT = settings.DJANGO_PROJECT_DIR -def html_inline_css(html): +def _html_inline_css(html): inliner = css_inline.CSSInliner( inline_style_tags=True, keep_style_tags=False, keep_link_tags=False, - load_remote_stylesheets=False, extra_css=None, - base_url=f"file://{settings.STATIC_ROOT}", + # only load our own static files + load_remote_stylesheets=True, + # link urls will have been transformed earlier + base_url=f"file://{FILE_ROOT}/", ) - html = inliner.inline(html) - return html + try: + html = inliner.inline(html) + return html + except css_inline.InlineError as e: + # we never want errors to block important mail + # maybe we should log though + if settings.DEBUG: + raise e + else: + return html -def process_html(html, base_url="", extract_attachments=True, inline_css=True): +def process_html(html, base_url, extract_attachments=True, inline_css=True): + # TODO handle errors in cosmetics and make sure we always produce something parser = etree.HTMLParser() root = etree.fromstring(html, parser) + static_url = make_url_absolute(settings.STATIC_URL, base_url) + image_attachments = dict() url_cid_cache = dict() + absolute_attribs = [ + (".//a", "href"), + (".//img", "src"), + (".//link", "href"), + ] + + for selector, attr in absolute_attribs: + for elem in root.iterfind(selector): + url = elem.get(attr) + if not url: + continue + elem.set(attr, make_url_absolute(url, base_url)) + if extract_attachments: # extract and swap related content ID's - for img in root.iterfind(".//img"): - url = img.get("src") + for elem in root.iterfind(".//img"): + url = elem.get("src") if not url: continue # cache cid & content for deduplication (eg: icons) if url in url_cid_cache: cid = url_cid_cache[url] + if cid is None: + # we remembered this was a bad url + continue else: content, content_type = load_image(url, base_url) + if not content: + # if we can't load the image leave element as-is and remember bad url + url_cid_cache[url] = None + continue cid = cid_for_bytes(content) + url_cid_cache[url] = cid image_attachments[cid] = (content, content_type) - img.set("src", f"cid:{cid}") - fix_attribs = [ - (".//a", "href"), - ] - if not extract_attachments: - fix_attribs += [ - (".//img", "src"), - ] - if not inline_css: - fix_attribs += [ - (".//link", "href"), - ] - - for selector, attr in fix_attribs: - for elem in root.iterfind(selector): - url = elem.get(attr) - if not url: - continue - elem.set(attr, make_url_absolute(url, base_url)) + elem.set("src", f"cid:{cid}") if inline_css: - for href_elem in root.iterfind(".//link"): - url = href_elem.get("href") + for elem in root.iterfind(".//link"): + url = elem.get("href") if not url: continue - if url.startswith(settings.STATIC_URL): - # this is needed so css-inliner's can use a file:// base_url - url = url[len(settings.STATIC_URL)] - href_elem.set("href", url) - else: - href_elem.set("href", make_url_absolute(url, base_url)) + # TODO handle potential sneaky relative .. in urls? + # url was made absolute earlier + # this is needed so css-inliner's can use a file:// base_url + url = _find_static_for_inliner(url, static_url) + elem.set("href", url) result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html").decode("utf8") if inline_css: - result = html_inline_css(result) + result = _html_inline_css(result) attachments = [(cid, content, ct) for cid, (content, ct) in image_attachments.items()] @@ -141,8 +111,95 @@ def process_html(html, base_url="", extract_attachments=True, inline_css=True): def cid_for_bytes(content): # let's hash content for de-duplication - h = hashlib.md5() + h = hashlib.sha1(usedforsecurity=False) h.update(content) return h.hexdigest() +def read_image_file(path): + try: + with open(path, "rb") as f: + content = f.read() + # is guess_type() what we want or do we look in the content? + content_type, _encoding = guess_type(path) + return content, content_type + except Exception: + # TODO stricter exception types + # we never want errors to block important mail + # maybe we should log though + return None, "" + + +def _find_static_for_inliner(url, static_url): + if url.startswith(static_url): + file_name = url[len(static_url):] + file_loc = os.path.join(settings.STATIC_ROOT, file_name) + if os.path.exists(file_loc): + return os.path.relpath(file_loc, start=FILE_ROOT) + else: + file_loc = finders.find(file_name) + if file_loc: + return os.path.relpath(file_loc, start=FILE_ROOT) + + return url + + +def load_image(url, base_url): + # TODO support data urls? + # TODO use storage backend API instead of manually building paths etc + + static_url = make_url_absolute(settings.STATIC_URL, base_url) + media_url = make_url_absolute(settings.MEDIA_URL, base_url) + content, content_type = None, "" + + url = make_url_absolute(url, base_url) + + if url.startswith(static_url): + file_name = url[len(static_url):] + file_path = os.path.join(settings.STATIC_ROOT, file_name) + + content, content_type = read_image_file(file_path) + if not content: + # fallback for development + file_path = finders.find(file_name) + if file_path: + content, content_type = read_image_file(file_path) + + elif url.startswith(media_url): + file_name = url[len(media_url):] + file_path = os.path.join(settings.MEDIA_ROOT, file_name) + + content, content_type = read_image_file(file_path) + # else: + # url = make_url_absolute(base_url) + # # TODO check domains? + # # TODO check status + # # TODO handle errors + # r = requests.get(url) + # content_type = r.headers["Content-Type"] + # content = r.content + + if not content or not content_type.startswith("image/"): + # TODO lets log + return None, "" + else: + return content, content_type + + +def make_url_absolute(url, base_url=""): + """ + base_url: https://domain + """ + # TODO surely there is a standard and proper way to do this? + # TODO we're using the path part as file path so we should handle sneaky attempts to use relative ".." + base_url = base_url.rstrip("/") + if not url: + if base_url: + return base_url + else: + return "/" + if "://" in url: + return url + if url[0] != "/": + url = f"/{url}" + return base_url + url diff --git a/mail_editor/views.py b/mail_editor/views.py index 873d5ff..fbea8ee 100644 --- a/mail_editor/views.py +++ b/mail_editor/views.py @@ -28,7 +28,7 @@ def get(self, request, *args, **kwargs): subject_ctx, body_ctx = template.get_preview_contexts() _subject, body = template.render(body_ctx, subject_ctx) - body, _attachments = process_html(body, extract_attachments=False, base_url=settings.MAIL_EDITOR_BASE_HOST) + body, _attachments = process_html(body, settings.MAIL_EDITOR_BASE_HOST, extract_attachments=False) return HttpResponse(body, content_type="text/html") diff --git a/tests/settings.py b/tests/settings.py index 1acaf16..2eb9c40 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,5 +1,7 @@ import os +DJANGO_PROJECT_DIR = os.path.dirname(__file__) + SECRET_KEY = 'supersekrit' DATABASES = { @@ -74,6 +76,14 @@ ] +STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] STATIC_URL = "/static/" STATIC_ROOT = os.path.join(os.path.dirname(__file__), "static") MEDIA_URL = "/media/" diff --git a/tests/static/css/style.css b/tests/static/css/style.css new file mode 100644 index 0000000..d224431 --- /dev/null +++ b/tests/static/css/style.css @@ -0,0 +1,3 @@ +h1 { + color: red; +} diff --git a/tests/test_process_html.py b/tests/test_process_html.py index 454198d..e977c63 100644 --- a/tests/test_process_html.py +++ b/tests/test_process_html.py @@ -1,5 +1,6 @@ import os +from django.conf import settings from django.test import TestCase from mail_editor.process import cid_for_bytes, load_image, make_url_absolute, process_html @@ -15,7 +16,7 @@ class ProcessTestCase(TestCase): @patch("mail_editor.process.load_image", return_value=(b"abc", "image/jpg")) def test_extract_images(self, m1, m2): html = '

' - result, objects = process_html(html) + result, objects = process_html(html, "https://example.com") expected_html = '

' @@ -24,7 +25,7 @@ def test_extract_images(self, m1, m2): def test_fix_anchor_urls(self): html = '

bar

' - result, objects = process_html(html, base_url="https://example.com") + result, objects = process_html(html, "https://example.com") expected_html = '

bar

' @@ -33,16 +34,18 @@ def test_fix_anchor_urls(self): def test_fix_link_urls(self): html = '' - result, objects = process_html(html, base_url="https://example.com") + result, objects = process_html(html, "https://example.com") expected_html = '' self.assertEqual(result.rstrip(), expected_html) self.assertEqual(objects, []) - def test_inline_css(self): - html = '

foo

' - result, objects = process_html(html) + def test_inline_css_link(self): + # TODO properly test both collected STATIC_ROOT and the development staticfiles.finders fallback + + html = '

foo

' + result, objects = process_html(html, "https://example.com") expected_html = '

foo

' @@ -71,20 +74,20 @@ def test_cid_for_bytes(self): self.assertNotEqual(cid_for_bytes(b"123"), cid_for_bytes(b"abc")) def test_load_image(self): - # TODO test url loader + # TODO properly test both collected STATIC_ROOT and the development staticfiles.finders fallback with self.subTest("static & png"): - with open(os.path.join(os.path.dirname(__file__), 'static/logo.png'), "rb") as f: + with open(os.path.join(settings.STATIC_ROOT, 'logo.png'), "rb") as f: expected = f.read() - actual, content_type = load_image("/static/logo.png","http://not_testserver") + actual, content_type = load_image("/static/logo.png","http://testserver") self.assertEqual(actual, expected) self.assertEqual(content_type, "image/png") with self.subTest("media & jpg"): - with open(os.path.join(os.path.dirname(__file__), 'media/logo.jpg'), "rb") as f: + with open(os.path.join(settings.MEDIA_ROOT, 'logo.jpg'), "rb") as f: expected = f.read() - actual, content_type = load_image("/media/logo.jpg","http://not_testserver") + actual, content_type = load_image("/media/logo.jpg", "http://testserver") self.assertEqual(actual, expected) self.assertEqual(content_type, "image/jpeg") From b414db1052b832638e35b6ab77804996a517c3bf Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Thu, 8 Feb 2024 13:27:03 +0100 Subject: [PATCH 07/10] Fixed some email multipart/attachments issues --- mail_editor/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mail_editor/models.py b/mail_editor/models.py index fdabd68..baa6026 100644 --- a/mail_editor/models.py +++ b/mail_editor/models.py @@ -162,10 +162,10 @@ def build_message(self, to_addresses, context, subj_context=None, txt=False, att text_body = txt or strip_tags(body) email_message = EmailMultiAlternatives( - subject=subject, body=body, from_email=django_settings.DEFAULT_FROM_EMAIL, + subject=subject, body=text_body, from_email=django_settings.DEFAULT_FROM_EMAIL, to=to_addresses, cc=cc_addresses, bcc=bcc_addresses) - email_message.content_subtype = "html" - email_message.attach_alternative(text_body, 'text/plain') + email_message.attach_alternative(body, "text/html") + email_message.mixed_subtype = "related" if attachments: for attachment in attachments: @@ -181,7 +181,7 @@ def build_message(self, to_addresses, context, subj_context=None, txt=False, att subtype = subtype.split("/", maxsplit=1) assert subtype[0] == "image" mime_image = MIMEImage(content, _subtype=subtype[1]) - mime_image.add_header('Content-ID', cid) + mime_image.add_header('Content-ID', f"<{cid}>") email_message.attach(mime_image) return email_message From 7b35200b8159df225d3dfc81b8b2c2c547228ddd Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Mon, 12 Feb 2024 09:43:17 +0100 Subject: [PATCH 08/10] Fixed some links resolve issues, added tests, ignoring SVG --- mail_editor/process.py | 45 +++++++++------- tests/settings.py | 2 +- tests/static_alternative/logo_copy.png | Bin 0 -> 35486 bytes tests/test_process_html.py | 71 ++++++++++++++++++++++--- tests/test_send.py | 12 +++-- 5 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 tests/static_alternative/logo_copy.png diff --git a/mail_editor/process.py b/mail_editor/process.py index 291c377..49a1748 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -17,6 +17,13 @@ FILE_ROOT = settings.DJANGO_PROJECT_DIR +supported_types = [ + 'image/jpeg', + 'image/png', + # add webp? + # NOT SVG! +] + def _html_inline_css(html): inliner = css_inline.CSSInliner( @@ -24,7 +31,6 @@ def _html_inline_css(html): keep_style_tags=False, keep_link_tags=False, extra_css=None, - # only load our own static files load_remote_stylesheets=True, # link urls will have been transformed earlier base_url=f"file://{FILE_ROOT}/", @@ -93,11 +99,13 @@ def process_html(html, base_url, extract_attachments=True, inline_css=True): url = elem.get("href") if not url: continue - # TODO handle potential sneaky relative .. in urls? - # url was made absolute earlier - # this is needed so css-inliner's can use a file:// base_url - url = _find_static_for_inliner(url, static_url) - elem.set("href", url) + # resolving back to paths is needed so css-inliner's can use a file:// base_url + partial_file_path = find_static_path_for_inliner(url, static_url) + if partial_file_path: + elem.set("href", partial_file_path) + else: + # remove this element because we don't want to load external stylesheets + elem.getparent().remove(elem) result = etree.tostring(root, encoding="utf8", pretty_print=False, method="html").decode("utf8") @@ -110,7 +118,7 @@ def process_html(html, base_url, extract_attachments=True, inline_css=True): def cid_for_bytes(content): - # let's hash content for de-duplication + # hash content for de-duplication h = hashlib.sha1(usedforsecurity=False) h.update(content) return h.hexdigest() @@ -130,24 +138,23 @@ def read_image_file(path): return None, "" -def _find_static_for_inliner(url, static_url): +def find_static_path_for_inliner(url, static_url): if url.startswith(static_url): file_name = url[len(static_url):] - file_loc = os.path.join(settings.STATIC_ROOT, file_name) - if os.path.exists(file_loc): - return os.path.relpath(file_loc, start=FILE_ROOT) + file_path = os.path.join(settings.STATIC_ROOT, file_name) + if os.path.exists(file_path): + return os.path.relpath(file_path, start=FILE_ROOT) else: - file_loc = finders.find(file_name) - if file_loc: - return os.path.relpath(file_loc, start=FILE_ROOT) + file_path = finders.find(file_name) + if file_path: + return os.path.relpath(file_path, start=FILE_ROOT) - return url + # we don't allow external links + return None def load_image(url, base_url): - # TODO support data urls? - # TODO use storage backend API instead of manually building paths etc - + # TODO support data urls? steal from mailcleaner static_url = make_url_absolute(settings.STATIC_URL, base_url) media_url = make_url_absolute(settings.MEDIA_URL, base_url) content, content_type = None, "" @@ -179,7 +186,7 @@ def load_image(url, base_url): # content_type = r.headers["Content-Type"] # content = r.content - if not content or not content_type.startswith("image/"): + if not content or content_type not in supported_types: # TODO lets log return None, "" else: diff --git a/tests/settings.py b/tests/settings.py index 2eb9c40..4091215 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -76,7 +76,7 @@ ] -STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] +STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static"), os.path.join(DJANGO_PROJECT_DIR, "static_alternative")] # List of finder classes that know how to find static files in # various locations. diff --git a/tests/static_alternative/logo_copy.png b/tests/static_alternative/logo_copy.png new file mode 100644 index 0000000000000000000000000000000000000000..115df0314261a799e8611590576911174135bb4d GIT binary patch literal 35486 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ðXxi4McZ&6wV#^0*lc^8=FtQ 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', message.body) - self.assertIn('',message_body) + self.assertIn('', message.body) - self.assertIn('',message_body) + self.assertIn('") self.assertEqual(attach["Content-Type"], "image/jpg") payload = b64encode(b"abc").decode("utf8") + "\n" self.assertEqual(attach.get_payload(), payload) From d0a278e3377e26b6628cddb91c8ff149b07c5975 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Tue, 13 Feb 2024 11:40:07 +0100 Subject: [PATCH 09/10] [#2108] Added 'reload template' action to admin --- mail_editor/admin.py | 13 ++++++++++++- mail_editor/models.py | 6 ++++++ tests/test_admin.py | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mail_editor/admin.py b/mail_editor/admin.py index 5dd0f58..c75723f 100644 --- a/mail_editor/admin.py +++ b/mail_editor/admin.py @@ -1,6 +1,6 @@ from django.conf import settings as django_settings from django.conf.urls import url -from django.contrib import admin +from django.contrib import admin, messages from django.urls import path, reverse from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ @@ -36,6 +36,9 @@ class MailTemplateAdmin(admin.ModelAdmin): 'template_type', 'subject', ) + actions = [ + "reload_templates", + ] form = MailTemplateForm @@ -113,3 +116,11 @@ def get_variable_help_text(self, obj): return obj.get_variable_help_text() get_variable_help_text.short_description = _('Subject variables') + + def reload_templates(self, request, queryset): + for template in queryset: + template.reload_template() + template.save() + self.message_user(request, _("Template '{name}' is reset").format(name=template.template_type), level=messages.SUCCESS) + + reload_templates.short_description = _('Reset templates (WARNING: overwrites current content)') diff --git a/mail_editor/models.py b/mail_editor/models.py index baa6026..3badc79 100644 --- a/mail_editor/models.py +++ b/mail_editor/models.py @@ -89,6 +89,12 @@ def clean(self): _('Mail template with this type and language already exists') ) + def reload_template(self): + from .helpers import get_base_template_path, get_body, get_subject + self.subject = get_subject(self.template_type) + self.body = get_body(self.template_type) + self.base_template_path = get_base_template_path(self.template_type) + def get_base_context(self): base_context = getattr(django_settings, 'MAIL_EDITOR_BASE_CONTEXT', {}).copy() dynamic = settings.DYNAMIC_CONTEXT diff --git a/tests/test_admin.py b/tests/test_admin.py index d9c8827..0675f20 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -47,6 +47,26 @@ def test_changelist_view(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) + def test_changelist_view__reset_template_action(self): + template = find_template("test_template") + template.body = "something else" + template.subject = "something else" + template.save() + + url = reverse('admin:{}_{}_changelist'.format(template._meta.app_label, template._meta.model_name)) + + self.client.force_login(self.super_user) + data = { + 'action': 'reload_templates', + '_selected_action': [str(template.pk)], + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + + template.refresh_from_db() + self.assertIn(str(_("Test mail sent from testcase with {{ id }}")), template.body, ) + self.assertEqual(template.subject, _("Important message for {{ id }}")) + def test_change_view(self): template = find_template("test_template") From 36ea073847bbea4aef3c97723f988927876b7ec9 Mon Sep 17 00:00:00 2001 From: Bart van der Schoor Date: Wed, 14 Feb 2024 11:57:58 +0100 Subject: [PATCH 10/10] Don't transform tel: and mailto: links --- mail_editor/process.py | 9 +++++++++ tests/test_process_html.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/mail_editor/process.py b/mail_editor/process.py index 49a1748..cfe5523 100644 --- a/mail_editor/process.py +++ b/mail_editor/process.py @@ -2,6 +2,7 @@ import itertools import os from mimetypes import guess_type +from urllib.parse import urlparse import css_inline import requests @@ -199,6 +200,14 @@ def make_url_absolute(url, base_url=""): """ # TODO surely there is a standard and proper way to do this? # TODO we're using the path part as file path so we should handle sneaky attempts to use relative ".." + try: + parse = urlparse(url) + # allow tel:, mailto: links + if parse.scheme and parse.scheme not in ("http", "https"): + return url + except ValueError: + pass + base_url = base_url.rstrip("/") if not url: if base_url: diff --git a/tests/test_process_html.py b/tests/test_process_html.py index 9f97aa7..d7d1c45 100644 --- a/tests/test_process_html.py +++ b/tests/test_process_html.py @@ -90,6 +90,9 @@ def test_make_url_absolute(self): ("", "http://example.com/foo", "http://example.com/foo"), ("", "foo", "/foo"), ("", "", "/"), + # extras + ("http://example.com", "tel:123456789", "tel:123456789"), + ("http://example.com", "mailto:foo@example.com", "mailto:foo@example.com"), ] for i, (base, url, expected) in enumerate(tests): with self.subTest((i, base, url)):

bar