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.
@@ -0,0 +1,2 @@
1
+ # flake8: noqa
2
+ from .main import EmailInfo, MessageDef, render_email
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ morpheus
@@ -0,0 +1 @@
1
+