morpheus-mail 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
morpheus/render/main.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import secrets
|
|
4
|
+
from base64 import urlsafe_b64encode
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
import chevron
|
|
9
|
+
import sass
|
|
10
|
+
from chevron import ChevronError
|
|
11
|
+
from misaka import HtmlRenderer, Markdown
|
|
12
|
+
|
|
13
|
+
markdown = Markdown(HtmlRenderer(flags=['hard-wrap']), extensions=['no-intra-emphasis'])
|
|
14
|
+
logger = logging.getLogger('render')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MessageDef:
|
|
19
|
+
first_name: str
|
|
20
|
+
last_name: str
|
|
21
|
+
main_template: str
|
|
22
|
+
mustache_partials: Dict[str, dict]
|
|
23
|
+
macros: Dict[str, dict]
|
|
24
|
+
subject_template: str
|
|
25
|
+
context: dict
|
|
26
|
+
headers: dict
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EmailInfo:
|
|
31
|
+
full_name: str
|
|
32
|
+
subject: str
|
|
33
|
+
html_body: str
|
|
34
|
+
headers: dict
|
|
35
|
+
shortened_link: list
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _update_context(context, partials, macros):
|
|
39
|
+
for k, v in context.items():
|
|
40
|
+
if k.endswith('__md'):
|
|
41
|
+
yield k[:-4], markdown(v)
|
|
42
|
+
elif k.endswith('__sass'):
|
|
43
|
+
yield k[:-6], sass.compile(string=v, output_style='compressed', precision=10).strip('\n')
|
|
44
|
+
elif k.endswith('__render'):
|
|
45
|
+
v = chevron.render(_apply_macros(v, macros), data=context, partials_dict=partials)
|
|
46
|
+
yield k[:-8], markdown(v)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _apply_macros(s, macros):
|
|
50
|
+
if macros:
|
|
51
|
+
for key, body in macros.items():
|
|
52
|
+
m = re.search(r'^(\S+)\((.*)\) *$', key)
|
|
53
|
+
if not m:
|
|
54
|
+
logger.warning('invalid macro "%s", skipping it', key)
|
|
55
|
+
continue
|
|
56
|
+
name, arg_defs = m.groups()
|
|
57
|
+
arg_defs = [a.strip(' ') for a in arg_defs.split('|') if a.strip(' ')]
|
|
58
|
+
|
|
59
|
+
def replace_macro(m):
|
|
60
|
+
arg_values = [a.strip(' ') for a in m.groups()[0].split('|') if a.strip(' ')]
|
|
61
|
+
if len(arg_defs) != len(arg_values):
|
|
62
|
+
logger.warning('invalid macro call "%s", not replacing', m.group())
|
|
63
|
+
return m.group()
|
|
64
|
+
else:
|
|
65
|
+
return chevron.render(body, data=dict(zip(arg_defs, arg_values)))
|
|
66
|
+
|
|
67
|
+
s = re.sub(r'%s\((.*?)\)' % name, replace_macro, s)
|
|
68
|
+
return s
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
SKIPPED_LINKS = [
|
|
72
|
+
re.compile(r'\.(?:png|jpg|bmp)$'),
|
|
73
|
+
re.compile(r'^https?://maps.googleapis.com'),
|
|
74
|
+
re.compile(r'^https?://maps.google.com'),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def looks_like_link(s):
|
|
79
|
+
return isinstance(s, str) and re.match('^https?://', s) and not any(m.search(s) for m in SKIPPED_LINKS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_short_links(context, click_url, click_random=30, backup_arg=False):
|
|
83
|
+
shortened_link = []
|
|
84
|
+
extra = {}
|
|
85
|
+
for k, v in context.items():
|
|
86
|
+
# TODO deal with unsubscribe links properly
|
|
87
|
+
if k != 'unsubscribe_link' and looks_like_link(v):
|
|
88
|
+
r = secrets.token_urlsafe(click_random)[:click_random]
|
|
89
|
+
new_url = click_url + r
|
|
90
|
+
if backup_arg:
|
|
91
|
+
new_url += '?u=' + urlsafe_b64encode(v.encode()).decode()
|
|
92
|
+
extra[k] = new_url
|
|
93
|
+
extra[f'{k}_original'] = v
|
|
94
|
+
shortened_link.append((v, r))
|
|
95
|
+
context.update(extra)
|
|
96
|
+
return shortened_link
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_email(m: MessageDef, click_url=None, click_random=30) -> EmailInfo:
|
|
100
|
+
full_name = f'{m.first_name or ""} {m.last_name or ""}'.strip(' ')
|
|
101
|
+
m.context.setdefault('recipient_name', full_name)
|
|
102
|
+
m.context.setdefault('recipient_first_name', m.first_name or full_name)
|
|
103
|
+
m.context.setdefault('recipient_last_name', m.last_name)
|
|
104
|
+
try:
|
|
105
|
+
subject = chevron.render(m.subject_template, data=m.context)
|
|
106
|
+
except ChevronError as e:
|
|
107
|
+
logger.warning('invalid subject template: %s', e)
|
|
108
|
+
subject = m.subject_template
|
|
109
|
+
|
|
110
|
+
shortened_link = []
|
|
111
|
+
if click_url:
|
|
112
|
+
shortened_link = apply_short_links(m.context, click_url, click_random, backup_arg=True)
|
|
113
|
+
m.context.update(email_subject=subject, **dict(_update_context(m.context, m.mustache_partials, m.macros)))
|
|
114
|
+
unsubscribe_link = m.context.get('unsubscribe_link')
|
|
115
|
+
if unsubscribe_link:
|
|
116
|
+
m.headers.setdefault('List-Unsubscribe', f'<{unsubscribe_link}>')
|
|
117
|
+
|
|
118
|
+
return EmailInfo(
|
|
119
|
+
full_name=full_name,
|
|
120
|
+
subject=subject,
|
|
121
|
+
html_body=chevron.render(
|
|
122
|
+
_apply_macros(m.main_template, m.macros), data=m.context, partials_dict=m.mustache_partials
|
|
123
|
+
),
|
|
124
|
+
headers=m.headers,
|
|
125
|
+
shortened_link=shortened_link,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
BASIC_CHARACTERS = {
|
|
130
|
+
# basic characters from https://support.messagebird.com/hc/en-us/articles/208739765-Which-special-
|
|
131
|
+
# characters-count-as-two-characters-in-a-text-message- ordered and repeats removed!
|
|
132
|
+
' ',
|
|
133
|
+
'!',
|
|
134
|
+
'"',
|
|
135
|
+
'#',
|
|
136
|
+
'$',
|
|
137
|
+
'%',
|
|
138
|
+
'&',
|
|
139
|
+
"'",
|
|
140
|
+
'(',
|
|
141
|
+
')',
|
|
142
|
+
'*',
|
|
143
|
+
'+',
|
|
144
|
+
',',
|
|
145
|
+
'-',
|
|
146
|
+
'.',
|
|
147
|
+
'/',
|
|
148
|
+
'0',
|
|
149
|
+
'1',
|
|
150
|
+
'2',
|
|
151
|
+
'3',
|
|
152
|
+
'4',
|
|
153
|
+
'5',
|
|
154
|
+
'6',
|
|
155
|
+
'7',
|
|
156
|
+
'8',
|
|
157
|
+
'9',
|
|
158
|
+
':',
|
|
159
|
+
';',
|
|
160
|
+
'<',
|
|
161
|
+
'=',
|
|
162
|
+
'>',
|
|
163
|
+
'?',
|
|
164
|
+
'@',
|
|
165
|
+
'A',
|
|
166
|
+
'B',
|
|
167
|
+
'C',
|
|
168
|
+
'D',
|
|
169
|
+
'E',
|
|
170
|
+
'F',
|
|
171
|
+
'G',
|
|
172
|
+
'H',
|
|
173
|
+
'I',
|
|
174
|
+
'J',
|
|
175
|
+
'K',
|
|
176
|
+
'L',
|
|
177
|
+
'M',
|
|
178
|
+
'N',
|
|
179
|
+
'O',
|
|
180
|
+
'P',
|
|
181
|
+
'Q',
|
|
182
|
+
'R',
|
|
183
|
+
'S',
|
|
184
|
+
'T',
|
|
185
|
+
'U',
|
|
186
|
+
'V',
|
|
187
|
+
'W',
|
|
188
|
+
'X',
|
|
189
|
+
'Y',
|
|
190
|
+
'Z',
|
|
191
|
+
'_',
|
|
192
|
+
'a',
|
|
193
|
+
'b',
|
|
194
|
+
'c',
|
|
195
|
+
'd',
|
|
196
|
+
'e',
|
|
197
|
+
'f',
|
|
198
|
+
'g',
|
|
199
|
+
'h',
|
|
200
|
+
'i',
|
|
201
|
+
'j',
|
|
202
|
+
'k',
|
|
203
|
+
'l',
|
|
204
|
+
'm',
|
|
205
|
+
'n',
|
|
206
|
+
'o',
|
|
207
|
+
'p',
|
|
208
|
+
'q',
|
|
209
|
+
'r',
|
|
210
|
+
's',
|
|
211
|
+
't',
|
|
212
|
+
'u',
|
|
213
|
+
'v',
|
|
214
|
+
'w',
|
|
215
|
+
'x',
|
|
216
|
+
'y',
|
|
217
|
+
'z',
|
|
218
|
+
'¡',
|
|
219
|
+
'£',
|
|
220
|
+
'¤',
|
|
221
|
+
'¥',
|
|
222
|
+
'§',
|
|
223
|
+
'¿',
|
|
224
|
+
'Ä',
|
|
225
|
+
'Å',
|
|
226
|
+
'Æ',
|
|
227
|
+
'Ç',
|
|
228
|
+
'É',
|
|
229
|
+
'Ñ',
|
|
230
|
+
'Ö',
|
|
231
|
+
'Ø',
|
|
232
|
+
'Ü',
|
|
233
|
+
'ß',
|
|
234
|
+
'à',
|
|
235
|
+
'ä',
|
|
236
|
+
'å',
|
|
237
|
+
'æ',
|
|
238
|
+
'è',
|
|
239
|
+
'é',
|
|
240
|
+
'ì',
|
|
241
|
+
'ñ',
|
|
242
|
+
'ò',
|
|
243
|
+
'ö',
|
|
244
|
+
'ø',
|
|
245
|
+
'ù',
|
|
246
|
+
'ü',
|
|
247
|
+
'Γ',
|
|
248
|
+
'Δ',
|
|
249
|
+
'Θ',
|
|
250
|
+
'Λ',
|
|
251
|
+
'Ξ',
|
|
252
|
+
'Π',
|
|
253
|
+
'Σ',
|
|
254
|
+
'Φ',
|
|
255
|
+
'Ψ',
|
|
256
|
+
'Ω',
|
|
257
|
+
# special cases from https://support.messagebird.com/hc/en-gb/articles/200731072-In-which-charset-can-I-deliver-
|
|
258
|
+
# SMS-messages-and-what-should-I-take-into-consideration- this is not an exhaustive list :(
|
|
259
|
+
'ç',
|
|
260
|
+
'®',
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
# from first link above
|
|
264
|
+
# apparently messagebird call \n an extension character which differs from https://en.wikipedia.org/wiki/GSM_03.38
|
|
265
|
+
# this might be because they replace \n with LF + CR which would constitute 2 characters
|
|
266
|
+
EXTENSION_CHARACTERS = {'\n', '[', '\\', ']', '^', '{', '|', '}', '~', '€'}
|
|
267
|
+
|
|
268
|
+
# from https://support.messagebird.com/hc/en-us/articles/208739745-How-long-is-1-SMS-Message-
|
|
269
|
+
MULTIPART_LENGTHS = [(1, 160), (2, 306), (3, 459), (4, 612), (5, 765), (6, 918), (7, 1071), (8, 1224), (9, 1377)]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class SmsLength:
|
|
274
|
+
length: int
|
|
275
|
+
parts: int
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MessageTooLong(ValueError):
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def sms_length(msg: str) -> SmsLength:
|
|
283
|
+
"""
|
|
284
|
+
:param msg: msg string
|
|
285
|
+
:return: tuple (length of the message, number of multi-part SMSs required)
|
|
286
|
+
"""
|
|
287
|
+
length = 0
|
|
288
|
+
for c in msg:
|
|
289
|
+
if c in BASIC_CHARACTERS:
|
|
290
|
+
length += 1
|
|
291
|
+
elif c in EXTENSION_CHARACTERS:
|
|
292
|
+
length += 2
|
|
293
|
+
# in theory all other characters are unavailable in GSM 03.38 and will be stripped out
|
|
294
|
+
|
|
295
|
+
for msg_parts, max_length in MULTIPART_LENGTHS:
|
|
296
|
+
if length <= max_length:
|
|
297
|
+
return SmsLength(length, msg_parts)
|
|
298
|
+
raise MessageTooLong(f'message length {length} exceeds maximum multi-part SMS length {max_length}')
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: morpheus-mail
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Email rendering engine from morpheus
|
|
5
|
+
Home-page: https://github.com/tutorcruncher/morpheus
|
|
6
|
+
Author: TutorCruncher
|
|
7
|
+
Author-email: tom@tutorcruncher.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: Unix
|
|
20
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
21
|
+
Classifier: Topic :: Internet
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: chevron>=0.11.1
|
|
24
|
+
Requires-Dist: libsass>=0.13.2
|
|
25
|
+
Requires-Dist: misaka>=2.1.1
|
|
26
|
+
Dynamic: author
|
|
27
|
+
Dynamic: author-email
|
|
28
|
+
Dynamic: classifier
|
|
29
|
+
Dynamic: description
|
|
30
|
+
Dynamic: home-page
|
|
31
|
+
Dynamic: license
|
|
32
|
+
Dynamic: requires-dist
|
|
33
|
+
Dynamic: requires-python
|
|
34
|
+
Dynamic: summary
|
|
35
|
+
|
|
36
|
+
Note: this only installs the rendering logic for `morpheus <https://github.com/tutorcruncher/morpheus>` for testing and email preview.
|
|
37
|
+
|
|
38
|
+
Everything else is excluded to avoid installing unnecessary packages.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
morpheus/render/__init__.py,sha256=EwrQtuF-lJ8K7QpTSr-UXwpaCjCwnBpKH3o54EoqFUM,69
|
|
2
|
+
morpheus/render/main.py,sha256=NK4pwDgaITgSNqjLDM0oSq6nZPLJc716OOBXGkv3cqU,7108
|
|
3
|
+
morpheus_mail-2.0.0.dist-info/METADATA,sha256=Z8aeop_73U5hC8RVFGKCgtrnno8sN6_qBrR6fhezGJ0,1325
|
|
4
|
+
morpheus_mail-2.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
morpheus_mail-2.0.0.dist-info/top_level.txt,sha256=aNjaI3nSmb2V0JLcFa8UdvPHEQi71sA5AIKB3WWj7S8,9
|
|
6
|
+
morpheus_mail-2.0.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
morpheus_mail-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
morpheus
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|