├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .pyup.yml
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── README.rst
├── dev.txt
├── mjml
├── __init__.py
├── apps.py
├── settings.py
├── templatetags
│ ├── __init__.py
│ └── mjml.py
└── tools.py
├── requirements.txt
├── setup.py
├── testprj
├── __init__.py
├── settings.py
├── tests.py
├── tests_cmdmode.py
├── tests_httpserver.py
├── tests_tcpserver.py
├── tools.py
├── urls.py
└── wsgi.py
└── tools.py
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | pull_request:
6 | types: [ opened, synchronize ]
7 |
8 | jobs:
9 | test-py-3-6:
10 | runs-on: ubuntu-20.04 # Python 3.6 is not available in newer releases
11 | env:
12 | PYTHON_VER: 3.6
13 | NODE_VER: 20.x
14 | strategy:
15 | matrix:
16 | django-ver: [ '<2.3', '<3.1', '<3.2', '<3.3' ]
17 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
18 | tcp-server-ver: [ 'v1.2' ]
19 | fail-fast: false
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v4
23 | - name: Checkout tcp server
24 | uses: actions/checkout@v4
25 | with:
26 | repository: 'liminspace/mjml-tcpserver'
27 | ref: ${{ matrix.tcp-server-ver }}
28 | path: './mjml-tcpserver'
29 | - name: Set up Python ${{ env.PYTHON_VER }}
30 | uses: actions/setup-python@v5
31 | with:
32 | python-version: ${{ env.PYTHON_VER }}
33 | - name: Cache pip
34 | uses: actions/cache@v4
35 | env:
36 | cache-name: cache-pip
37 | with:
38 | path: ~/.cache/pip
39 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
40 | restore-keys: |
41 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
42 | - name: Install Python dependencies
43 | run: |
44 | pip install "Django${{ matrix.django-ver }}"
45 | pip install "requests>=2.24.0,<2.28.0"
46 | - name: Set up Node.js ${{ env.NODE_VER }}
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: ${{ env.NODE_VER }}
50 | - name: Cache npm
51 | uses: actions/cache@v4
52 | env:
53 | cache-name: cache-npm
54 | with:
55 | path: ~/.npm
56 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
57 | restore-keys: |
58 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
59 | - name: Install Node dependencies
60 | run: |
61 | npm cache verify
62 | npm install -g mjml-http-server@0.1.0
63 | npm install mjml@${{ matrix.mjml-ver }}
64 | - name: Show info
65 | run: |
66 | node_modules/.bin/mjml --version
67 | - name: Test
68 | run: |
69 | python tools.py test
70 | test-py-3-7:
71 | runs-on: ubuntu-22.04 # Python 3.7 is not available in newer releases
72 | env:
73 | PYTHON_VER: 3.7
74 | NODE_VER: 20.x
75 | strategy:
76 | matrix:
77 | django-ver: [ '<2.3', '<3.1', '<3.2', '<3.3' ]
78 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
79 | tcp-server-ver: [ 'v1.2' ]
80 | fail-fast: false
81 | steps:
82 | - name: Checkout
83 | uses: actions/checkout@v4
84 | - name: Checkout tcp server
85 | uses: actions/checkout@v4
86 | with:
87 | repository: 'liminspace/mjml-tcpserver'
88 | ref: ${{ matrix.tcp-server-ver }}
89 | path: './mjml-tcpserver'
90 | - name: Set up Python ${{ env.PYTHON_VER }}
91 | uses: actions/setup-python@v5
92 | with:
93 | python-version: ${{ env.PYTHON_VER }}
94 | - name: Cache pip
95 | uses: actions/cache@v4
96 | env:
97 | cache-name: cache-pip
98 | with:
99 | path: ~/.cache/pip
100 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
101 | restore-keys: |
102 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
103 | - name: Install Python dependencies
104 | run: |
105 | pip install "Django${{ matrix.django-ver }}"
106 | pip install "requests>=2.24.0,<=2.29.0"
107 | - name: Set up Node.js ${{ env.NODE_VER }}
108 | uses: actions/setup-node@v4
109 | with:
110 | node-version: ${{ env.NODE_VER }}
111 | - name: Cache npm
112 | uses: actions/cache@v4
113 | env:
114 | cache-name: cache-npm
115 | with:
116 | path: ~/.npm
117 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
118 | restore-keys: |
119 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
120 | - name: Install Node dependencies
121 | run: |
122 | npm cache verify
123 | npm install -g mjml-http-server@0.1.0
124 | npm install mjml@${{ matrix.mjml-ver }}
125 | - name: Show info
126 | run: |
127 | node_modules/.bin/mjml --version
128 | - name: Test
129 | run: |
130 | python tools.py test
131 | test-py-3-8:
132 | runs-on: ubuntu-latest
133 | env:
134 | PYTHON_VER: 3.8
135 | NODE_VER: 20.x
136 | strategy:
137 | matrix:
138 | django-ver: [ '<2.3', '<3.1', '<3.2', '<3.3', '<4.1', '<4.2', '<4.3' ]
139 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
140 | tcp-server-ver: [ 'v1.2' ]
141 | fail-fast: false
142 | steps:
143 | - name: Checkout
144 | uses: actions/checkout@v4
145 | - name: Checkout tcp server
146 | uses: actions/checkout@v4
147 | with:
148 | repository: 'liminspace/mjml-tcpserver'
149 | ref: ${{ matrix.tcp-server-ver }}
150 | path: './mjml-tcpserver'
151 | - name: Set up Python ${{ env.PYTHON_VER }}
152 | uses: actions/setup-python@v5
153 | with:
154 | python-version: ${{ env.PYTHON_VER }}
155 | - name: Cache pip
156 | uses: actions/cache@v4
157 | env:
158 | cache-name: cache-pip
159 | with:
160 | path: ~/.cache/pip
161 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
162 | restore-keys: |
163 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
164 | - name: Install Python dependencies
165 | run: |
166 | pip install "Django${{ matrix.django-ver }}"
167 | pip install "requests>=2.24.0,<=2.29.0"
168 | - name: Set up Node.js ${{ env.NODE_VER }}
169 | uses: actions/setup-node@v4
170 | with:
171 | node-version: ${{ env.NODE_VER }}
172 | - name: Cache npm
173 | uses: actions/cache@v4
174 | env:
175 | cache-name: cache-npm
176 | with:
177 | path: ~/.npm
178 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
179 | restore-keys: |
180 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
181 | - name: Install Node dependencies
182 | run: |
183 | npm cache verify
184 | npm install -g mjml-http-server@0.1.0
185 | npm install mjml@${{ matrix.mjml-ver }}
186 | - name: Show info
187 | run: |
188 | node_modules/.bin/mjml --version
189 | - name: Test
190 | run: |
191 | python tools.py test
192 | test-py-3-9:
193 | runs-on: ubuntu-latest
194 | env:
195 | PYTHON_VER: 3.9
196 | NODE_VER: 20.x
197 | strategy:
198 | matrix:
199 | django-ver: [ '<2.3', '<3.1', '<3.2', '<3.3', '<4.1', '<4.2', '<4.3' ]
200 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
201 | tcp-server-ver: [ 'v1.2' ]
202 | fail-fast: false
203 | steps:
204 | - name: Checkout
205 | uses: actions/checkout@v4
206 | - name: Checkout tcp server
207 | uses: actions/checkout@v4
208 | with:
209 | repository: 'liminspace/mjml-tcpserver'
210 | ref: ${{ matrix.tcp-server-ver }}
211 | path: './mjml-tcpserver'
212 | - name: Set up Python ${{ env.PYTHON_VER }}
213 | uses: actions/setup-python@v5
214 | with:
215 | python-version: ${{ env.PYTHON_VER }}
216 | - name: Cache pip
217 | uses: actions/cache@v4
218 | env:
219 | cache-name: cache-pip
220 | with:
221 | path: ~/.cache/pip
222 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
223 | restore-keys: |
224 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
225 | - name: Install Python dependencies
226 | run: |
227 | pip install "Django${{ matrix.django-ver }}"
228 | pip install "requests>=2.24.0,<=2.29.0"
229 | - name: Set up Node.js ${{ env.NODE_VER }}
230 | uses: actions/setup-node@v4
231 | with:
232 | node-version: ${{ env.NODE_VER }}
233 | - name: Cache npm
234 | uses: actions/cache@v4
235 | env:
236 | cache-name: cache-npm
237 | with:
238 | path: ~/.npm
239 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
240 | restore-keys: |
241 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
242 | - name: Install Node dependencies
243 | run: |
244 | npm cache verify
245 | npm install -g mjml-http-server@0.1.0
246 | npm install mjml@${{ matrix.mjml-ver }}
247 | - name: Show info
248 | run: |
249 | node_modules/.bin/mjml --version
250 | - name: Test
251 | run: |
252 | python tools.py test
253 | test-py-3-10:
254 | runs-on: ubuntu-latest
255 | env:
256 | PYTHON_VER: '3.10'
257 | NODE_VER: 20.x
258 | strategy:
259 | matrix:
260 | django-ver: [ '<3.3', '<4.1', '<4.2', '<4.3', '<5.2', '<5.3' ]
261 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
262 | tcp-server-ver: [ 'v1.2' ]
263 | fail-fast: false
264 | steps:
265 | - name: Checkout
266 | uses: actions/checkout@v4
267 | - name: Checkout tcp server
268 | uses: actions/checkout@v4
269 | with:
270 | repository: 'liminspace/mjml-tcpserver'
271 | ref: ${{ matrix.tcp-server-ver }}
272 | path: './mjml-tcpserver'
273 | - name: Set up Python ${{ env.PYTHON_VER }}
274 | uses: actions/setup-python@v5
275 | with:
276 | python-version: ${{ env.PYTHON_VER }}
277 | - name: Cache pip
278 | uses: actions/cache@v4
279 | env:
280 | cache-name: cache-pip
281 | with:
282 | path: ~/.cache/pip
283 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
284 | restore-keys: |
285 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
286 | - name: Install Python dependencies
287 | run: |
288 | pip install "Django${{ matrix.django-ver }}"
289 | pip install "requests>=2.24.0,<=2.29.0"
290 | - name: Set up Node.js ${{ env.NODE_VER }}
291 | uses: actions/setup-node@v4
292 | with:
293 | node-version: ${{ env.NODE_VER }}
294 | - name: Cache npm
295 | uses: actions/cache@v4
296 | env:
297 | cache-name: cache-npm
298 | with:
299 | path: ~/.npm
300 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
301 | restore-keys: |
302 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
303 | - name: Install Node dependencies
304 | run: |
305 | npm cache verify
306 | npm install -g mjml-http-server@0.1.0
307 | npm install mjml@${{ matrix.mjml-ver }}
308 | - name: Show info
309 | run: |
310 | node_modules/.bin/mjml --version
311 | - name: Test
312 | run: |
313 | python tools.py test
314 | test-py-3-11:
315 | runs-on: ubuntu-latest
316 | env:
317 | PYTHON_VER: '3.11'
318 | NODE_VER: 20.x
319 | strategy:
320 | matrix:
321 | django-ver: [ '<4.2', '<4.3', '<5.2', '<5.3' ]
322 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
323 | tcp-server-ver: [ 'v1.2' ]
324 | fail-fast: false
325 | steps:
326 | - name: Checkout
327 | uses: actions/checkout@v4
328 | - name: Checkout tcp server
329 | uses: actions/checkout@v4
330 | with:
331 | repository: 'liminspace/mjml-tcpserver'
332 | ref: ${{ matrix.tcp-server-ver }}
333 | path: './mjml-tcpserver'
334 | - name: Set up Python ${{ env.PYTHON_VER }}
335 | uses: actions/setup-python@v5
336 | with:
337 | python-version: ${{ env.PYTHON_VER }}
338 | - name: Cache pip
339 | uses: actions/cache@v4
340 | env:
341 | cache-name: cache-pip
342 | with:
343 | path: ~/.cache/pip
344 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
345 | restore-keys: |
346 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
347 | - name: Install Python dependencies
348 | run: |
349 | pip install "Django${{ matrix.django-ver }}"
350 | pip install "requests>=2.24.0,<=2.29.0"
351 | - name: Set up Node.js ${{ env.NODE_VER }}
352 | uses: actions/setup-node@v4
353 | with:
354 | node-version: ${{ env.NODE_VER }}
355 | - name: Cache npm
356 | uses: actions/cache@v4
357 | env:
358 | cache-name: cache-npm
359 | with:
360 | path: ~/.npm
361 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
362 | restore-keys: |
363 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
364 | - name: Install Node dependencies
365 | run: |
366 | npm cache verify
367 | npm install -g mjml-http-server@0.1.0
368 | npm install mjml@${{ matrix.mjml-ver }}
369 | - name: Show info
370 | run: |
371 | node_modules/.bin/mjml --version
372 | - name: Test
373 | run: |
374 | python tools.py test
375 | test-py-3-12:
376 | runs-on: ubuntu-latest
377 | env:
378 | PYTHON_VER: '3.12'
379 | NODE_VER: 20.x
380 | strategy:
381 | matrix:
382 | django-ver: [ '<4.3', '<5.2', '<5.3' ]
383 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
384 | tcp-server-ver: [ 'v1.2' ]
385 | fail-fast: false
386 | steps:
387 | - name: Checkout
388 | uses: actions/checkout@v4
389 | - name: Checkout tcp server
390 | uses: actions/checkout@v4
391 | with:
392 | repository: 'liminspace/mjml-tcpserver'
393 | ref: ${{ matrix.tcp-server-ver }}
394 | path: './mjml-tcpserver'
395 | - name: Set up Python ${{ env.PYTHON_VER }}
396 | uses: actions/setup-python@v5
397 | with:
398 | python-version: ${{ env.PYTHON_VER }}
399 | - name: Cache pip
400 | uses: actions/cache@v4
401 | env:
402 | cache-name: cache-pip
403 | with:
404 | path: ~/.cache/pip
405 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
406 | restore-keys: |
407 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
408 | - name: Install Python dependencies
409 | run: |
410 | pip install "Django${{ matrix.django-ver }}"
411 | pip install "requests>=2.24.0,<=2.29.0"
412 | - name: Set up Node.js ${{ env.NODE_VER }}
413 | uses: actions/setup-node@v4
414 | with:
415 | node-version: ${{ env.NODE_VER }}
416 | - name: Cache npm
417 | uses: actions/cache@v4
418 | env:
419 | cache-name: cache-npm
420 | with:
421 | path: ~/.npm
422 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
423 | restore-keys: |
424 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
425 | - name: Install Node dependencies
426 | run: |
427 | npm cache verify
428 | npm install -g mjml-http-server@0.1.0
429 | npm install mjml@${{ matrix.mjml-ver }}
430 | - name: Show info
431 | run: |
432 | node_modules/.bin/mjml --version
433 | - name: Test
434 | run: |
435 | python tools.py test
436 | test-py-3-13:
437 | runs-on: ubuntu-latest
438 | env:
439 | PYTHON_VER: '3.13'
440 | NODE_VER: 20.x
441 | strategy:
442 | matrix:
443 | django-ver: [ '<5.2', '<5.3' ]
444 | mjml-ver: [ '4.7.1', '4.8.2', '4.9.3', '4.10.4', '4.11.0', '4.12.0', '4.13.0', '4.14.1', '4.15.2' ]
445 | tcp-server-ver: [ 'v1.2' ]
446 | fail-fast: false
447 | steps:
448 | - name: Checkout
449 | uses: actions/checkout@v4
450 | - name: Checkout tcp server
451 | uses: actions/checkout@v4
452 | with:
453 | repository: 'liminspace/mjml-tcpserver'
454 | ref: ${{ matrix.tcp-server-ver }}
455 | path: './mjml-tcpserver'
456 | - name: Set up Python ${{ env.PYTHON_VER }}
457 | uses: actions/setup-python@v5
458 | with:
459 | python-version: ${{ env.PYTHON_VER }}
460 | - name: Cache pip
461 | uses: actions/cache@v4
462 | env:
463 | cache-name: cache-pip
464 | with:
465 | path: ~/.cache/pip
466 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-${{ matrix.django-ver }}
467 | restore-keys: |
468 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.PYTHON_VER }}-
469 | - name: Install Python dependencies
470 | run: |
471 | pip install "Django${{ matrix.django-ver }}"
472 | pip install "requests>=2.24.0,<=2.29.0"
473 | - name: Set up Node.js ${{ env.NODE_VER }}
474 | uses: actions/setup-node@v4
475 | with:
476 | node-version: ${{ env.NODE_VER }}
477 | - name: Cache npm
478 | uses: actions/cache@v4
479 | env:
480 | cache-name: cache-npm
481 | with:
482 | path: ~/.npm
483 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-${{ matrix.mjml-ver }}
484 | restore-keys: |
485 | ${{ runner.os }}-${{ env.cache-name }}-${{ env.NODE_VER }}-
486 | - name: Install Node dependencies
487 | run: |
488 | npm cache verify
489 | npm install -g mjml-http-server@0.1.0
490 | npm install mjml@${{ matrix.mjml-ver }}
491 | - name: Show info
492 | run: |
493 | node_modules/.bin/mjml --version
494 | - name: Test
495 | run: |
496 | python tools.py test
497 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 | .python-version
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *,cover
47 | .hypothesis/
48 |
49 | # Translations
50 | *.mo
51 | *.pot
52 |
53 | # Django stuff:
54 | *.log
55 |
56 | # Sphinx documentation
57 | docs/_build/
58 |
59 | # PyBuilder
60 | target/
61 |
62 | #Ipython Notebook
63 | .ipynb_checkpoints
64 |
65 | # IDE
66 | .idea
67 | .vscode
68 |
69 | # db
70 | tests/db.sqlite3
71 |
72 | # node
73 | node_modules/
74 | package-lock.json
75 | package.json
76 |
77 |
78 | # tcpserver
79 | mjml-tcpserver/
80 |
--------------------------------------------------------------------------------
/.pyup.yml:
--------------------------------------------------------------------------------
1 | branch: main
2 | schedule: "every three months"
3 | search: False
4 |
5 | requirements:
6 | - requirements.txt:
7 | update: all
8 | pin: True
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | 1.4 (2025-04-06)
2 | ================
3 | * Added supporting Django 5.2
4 | * Added Python 3.13 in tests
5 |
6 |
7 | 1.3 (2024-08-21)
8 | ================
9 | * Added supporting Django 5.1
10 | * Added MJML 4.15.2 in tests
11 | * Removed MJML 4.6.3 from tests
12 | * Updated github-actions
13 |
14 |
15 | 1.2 (2024-01-08)
16 | ================
17 | * Added Python 3.12 in tests
18 | * Added supporting Django 5.0
19 |
20 |
21 | 1.1 (2023-04-11)
22 | ================
23 | * Added Python 3.11 in tests
24 | * Added supporting Django 4.2
25 | * Added MJML 4.14 in tests
26 | * Updated README
27 |
28 |
29 | 1.0 (2022-10-07)
30 | ================
31 | * Stopped supporting Python 2.7
32 | * Stopped supporting Django 1.8, 1.9, 1.10, 1.11, 2.0 and 2.1
33 | * Removed MJML 4.5 from tests
34 | * Added MJML 4.12 and 4.13 in tests
35 | * Moved MJML TCP-Server into separated repo https://github.com/danihodovic/mjml-server
36 | * Renamed base branch `master` to `main`
37 |
38 |
39 | 0.12.0 (2022-01-19)
40 | ===================
41 | * Added supporting Django 4.0
42 | * Upgraded MJML to 4.11.0 in dockerfile
43 | * Added Python 3.10 in tests
44 | * Added MJML 4.11.0 in tests
45 | * Removed MJML 4.4.0 from tests
46 |
47 |
48 | 0.11.0 (2021-07-04)
49 | ===================
50 | * Added supporting Django v3.2
51 | * Added Python 3.9 in tests
52 | * Added MJML 4.10.1 in tests
53 | * Removed Python 3.5 from tests
54 | * Removed MJML older than 4.4.0 from tests
55 | * Upgraded Node to v14 for tcp-server
56 | * Upgraded MJML to 4.9.3 in dockerfile
57 | * Moved from Travis to GitHub Actions
58 |
59 |
60 | 0.10.2 (2020-08-28)
61 | ===================
62 | * Import `requests` only if it's really needed
63 |
64 |
65 | 0.10.1 (2020-08-16)
66 | ===================
67 | * Added supporting Django v3.1
68 |
69 |
70 | 0.10.0 (2020-06-29)
71 | ===================
72 | * Added `requests` in extras require in `setup.py`
73 | * Added MJML 4.6.3 in tests
74 | * Upgraded MJML to 4.6.3 in dockerfile
75 | * Updated docs
76 |
77 |
78 | 0.9.0 (2019-12-24)
79 | ==================
80 | * Added supporting Django v3.0
81 | * Added supporting render http-server (including official MJML API https://mjml.io/api)
82 | * Added Python 3.8 in tests
83 | * Added MJML 4.5.1 in tests
84 | * Upgraded MJML to 4.5.1 in dockerfile
85 | * Upgraded Node to v12 for tcp-server
86 | * Reorganized tests
87 | * Updated docs
88 |
89 |
90 | 0.8.0 (2019-07-29)
91 | ==================
92 | * Fixed a trouble with unicode
93 | * Added MJML 4.4.0 in tests
94 | * Upgraded MJML to 4.4.0 in dockerfile
95 |
96 |
97 | 0.7.0 (2019-04-06)
98 | ==================
99 | * Removed MJML 4.0.5, 4.1.2 and 4.2.1 from tests
100 | * Added MJML 4.3.1 in tests
101 | * Updated tcp-server adding cleanly termination
102 | * Upgraded MJML to 4.3.1 in dockerfile
103 | * Updated dockerfile by using `exec`
104 | * Added supporting Django v2.2
105 |
106 |
107 | 0.6.0 (2018-12-06)
108 | ==================
109 | * Added `MJML_CHECK_CMD_ON_STARTUP` setting (thanks to Marcel Chastain)
110 | * Added Python 3.7 in tests
111 | * Added MJML v.4.2.1 in tests
112 | * Removed MJML v.2.3.3 from tests
113 | * Updated MJML to 4.2.1 in dockerfile
114 |
115 |
116 | 0.5.4 (2018-10-19)
117 | ==================
118 | * Fixed Popen PIPE subprocess deadlock by using TemporaryFile for stdout
119 |
120 |
121 | 0.5.3 (2018-08-07)
122 | ==================
123 | * Added supporting MJML v4.1.2
124 | * Added supporting Django v2.1
125 |
126 |
127 | 0.5.2 (2018-06-29)
128 | ==================
129 | * Added supporting MJML v4.1.0
130 | * Added .pyup.yaml
131 | * Updated tests
132 | * Added dockerfile for tcpserver
133 | * Remove mjml 3.0.2, 3.1.1 and 3.2.2 from tests
134 |
135 |
136 | 0.5.1 (2018-06-05)
137 | ==================
138 | * Add stopping tcpserver on SIGINT
139 |
140 |
141 | 0.5.0 (2018-04-28)
142 | ==================
143 | * Add support MJML v4
144 | * Tcpserver doesn't skip mjml errors now (thanks @yourcelf)
145 | * Refactor arguments in tcpserver
146 | * Fix incomplete sending data via socket (thanks @cavanierc)
147 |
148 |
149 | 0.4.0 (2018-01-10)
150 | ==================
151 | * Add support Django 2.0
152 | * Update support new versions of MJML (up to 3.3.5)
153 |
154 |
155 | 0.3.2 (2017-04-06)
156 | ==================
157 | * Add support Django 1.11
158 |
159 |
160 | 0.3.1 (2017-03-18)
161 | ==================
162 | * Update support new versions of MJML (up to 3.3.0)
163 |
164 |
165 | 0.3.0 (2017-03-03)
166 | ==================
167 | * Update support new versions of MJML (up to 3.2.2)
168 | * Add support Python 3.6
169 |
170 |
171 | 0.2.3 (2016-10-13)
172 | ==================
173 | * Add supporting django 1.8
174 |
175 |
176 | 0.2.2 (2016-08-15)
177 | ==================
178 | * Check mjml only if mode is "cmd"
179 |
180 |
181 | 0.2.1 (2016-08-03)
182 | ==================
183 | * Add support Django 1.10
184 |
185 |
186 | 0.2.0 (2016-07-24)
187 | ==================
188 | * Add backend mode TPCServer
189 | * Remove Python 3.4 from tests
190 | * Upgrade Django to 1.9.8 in tests
191 |
192 |
193 | 0.1.2 (2016-05-01)
194 | ==================
195 | * Fix release tools and setup.py
196 |
197 |
198 | 0.1.0 (2016-04-30)
199 | ==================
200 | * Migrate to MJML 2.x
201 | * Add support Python 3.4+
202 |
203 |
204 | 0.0.1 (2016-04-19)
205 | ==================
206 | * First release
207 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Igor Melnyk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 | include CHANGELOG.md
3 | include LICENSE
4 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | .. image:: https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct-single.svg
2 | :target: https://stand-with-ukraine.pp.ua
3 | :alt: Stand With Ukraine
4 |
5 | |
6 |
7 | .. image:: https://github.com/liminspace/django-mjml/actions/workflows/test.yml/badge.svg?branch=main
8 | :target: https://github.com/liminspace/django-mjml/actions/workflows/test.yml
9 | :alt: test
10 |
11 | .. image:: https://img.shields.io/pypi/v/django-mjml.svg
12 | :target: https://pypi.org/project/django-mjml/
13 | :alt: pypi
14 |
15 | |
16 |
17 | .. image:: https://cloud.githubusercontent.com/assets/5173158/14615647/5fc03bf8-05af-11e6-8cdd-f87bf432c4a2.png
18 | :target: #
19 | :alt: Django + MJML
20 |
21 | django-mjml
22 | ===========
23 |
24 | The simplest way to use `MJML `_ in `Django `_ templates.
25 |
26 | |
27 |
28 | Installation
29 | ------------
30 |
31 | Requirements:
32 | ^^^^^^^^^^^^^
33 |
34 | * ``Django`` from 2.2 to 5.2
35 | * ``requests`` from 2.24.0 (only if you are going to use API HTTP-server for rendering)
36 | * ``mjml`` from 4.7.1 to 4.15.2 (older version may work, but not tested anymore)
37 |
38 | **\1\. Install** ``mjml``.
39 |
40 | Follow https://github.com/mjmlio/mjml#installation and https://documentation.mjml.io/#installation to get more info.
41 |
42 | **\2\. Install** ``django-mjml``. ::
43 |
44 | $ pip install django-mjml
45 |
46 | If you want to use API HTTP-server you also need ``requests`` (at least version 2.24)::
47 |
48 | $ pip install django-mjml[requests]
49 |
50 | To install development version use ``git+https://github.com/liminspace/django-mjml.git@main`` instead ``django-mjml``.
51 |
52 | **\3\. Set up** ``settings.py`` **in your django project.** ::
53 |
54 | INSTALLED_APPS = (
55 | ...,
56 | 'mjml',
57 | )
58 |
59 | |
60 |
61 | Usage
62 | -----
63 |
64 | Load ``mjml`` in your django template and use ``mjml`` tag that will compile MJML to HTML::
65 |
66 | {% load mjml %}
67 |
68 | {% mjml %}
69 |
70 |
71 |
72 |
73 | Hello world!
74 |
75 |
76 |
77 |
78 | {% endmjml %}
79 |
80 | |
81 |
82 | Advanced settings
83 | -----------------
84 |
85 | There are three backend modes for compiling: ``cmd``, ``tcpserver`` and ``httpserver``.
86 |
87 | cmd mode
88 | ^^^^^^^^
89 |
90 | This mode is very simple, slow and used by default.
91 |
92 | Configure your Django::
93 |
94 | MJML_BACKEND_MODE = 'cmd'
95 | MJML_EXEC_CMD = 'mjml'
96 |
97 | You can change ``MJML_EXEC_CMD`` and set path to executable ``mjml`` file, for example::
98 |
99 | MJML_EXEC_CMD = '/home/user/node_modules/.bin/mjml'
100 |
101 | Also you can pass addition cmd arguments, for example::
102 |
103 | MJML_EXEC_CMD = ['node_modules/.bin/mjml', '--config.minify', 'true', '--config.validationLevel', 'strict']
104 |
105 | Once you have a working installation, you can skip the sanity check on startup to speed things up::
106 |
107 | MJML_CHECK_CMD_ON_STARTUP = False
108 |
109 | tcpserver mode
110 | ^^^^^^^^^^^^^^
111 |
112 | This mode is faster than ``cmd`` but it needs the `MJML TCP-Server `_.
113 |
114 | Configure your Django::
115 |
116 | MJML_BACKEND_MODE = 'tcpserver'
117 | MJML_TCPSERVERS = [
118 | ('127.0.0.1', 28101), # the host and port of MJML TCP-Server
119 | ]
120 |
121 | You can set several servers and a random one will be used::
122 |
123 | MJML_TCPSERVERS = [
124 | ('127.0.0.1', 28101),
125 | ('127.0.0.1', 28102),
126 | ('127.0.0.1', 28103),
127 | ]
128 |
129 | httpserver mode
130 | ^^^^^^^^^^^^^^^
131 |
132 | don't forget to install ``requests`` to use this mode.
133 |
134 | This mode is faster than ``cmd`` and a bit slower than ``tcpserver``, but you can use official MJML API https://mjml.io/api
135 | or run your own HTTP-server (for example https://github.com/danihodovic/mjml-server) to render templates.
136 |
137 | Configure your Django::
138 |
139 | MJML_BACKEND_MODE = 'httpserver'
140 | MJML_HTTPSERVERS = [
141 | {
142 | 'URL': 'https://api.mjml.io/v1/render', # official MJML API
143 | 'HTTP_AUTH': ('', ''),
144 | },
145 | {
146 | 'URL': 'http://127.0.0.1:38101/v1/render', # your own HTTP-server
147 | },
148 | ]
149 |
150 | You can set one or more servers and a random one will be used.
151 |
--------------------------------------------------------------------------------
/dev.txt:
--------------------------------------------------------------------------------
1 | # cd
2 |
3 | Install mjml:
4 | # npm i mjml
5 |
6 | Update mjml:
7 | # npm update mjml
8 |
9 | Install specific version:
10 | # npm i mjml@3.1.1
11 |
--------------------------------------------------------------------------------
/mjml/__init__.py:
--------------------------------------------------------------------------------
1 | import django
2 |
3 | __version__ = '1.4'
4 |
5 | if django.VERSION < (3, 2):
6 | default_app_config = 'mjml.apps.MJMLConfig'
7 |
--------------------------------------------------------------------------------
/mjml/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 | from django.core.exceptions import ImproperlyConfigured
3 |
4 | from mjml import settings as mjml_settings
5 | from mjml.tools import mjml_render
6 |
7 |
8 | def check_mjml_command() -> None:
9 | try:
10 | html = mjml_render(
11 | ''
12 | 'MJMLv3'
13 | ''
14 | )
15 | except RuntimeError:
16 | try:
17 | html = mjml_render(
18 | ''
19 | 'MJMLv4'
20 | ''
21 | )
22 | except RuntimeError as e:
23 | raise ImproperlyConfigured(e) from e
24 | if ' None:
36 | if mjml_settings.MJML_BACKEND_MODE == 'cmd' and mjml_settings.MJML_CHECK_CMD_ON_STARTUP:
37 | check_mjml_command()
38 |
--------------------------------------------------------------------------------
/mjml/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 | MJML_BACKEND_MODE = getattr(settings, 'MJML_BACKEND_MODE', 'cmd')
4 | assert MJML_BACKEND_MODE in {'cmd', 'tcpserver', 'httpserver'}
5 |
6 | # cmd backend mode configs
7 | MJML_EXEC_CMD = getattr(settings, 'MJML_EXEC_CMD', 'mjml')
8 | MJML_CHECK_CMD_ON_STARTUP = getattr(settings, 'MJML_CHECK_CMD_ON_STARTUP', True)
9 |
10 | # tcpserver backend mode configs
11 | MJML_TCPSERVERS = getattr(settings, 'MJML_TCPSERVERS', [('127.0.0.1', 28101)])
12 | assert isinstance(MJML_TCPSERVERS, (list, tuple))
13 | for t in MJML_TCPSERVERS:
14 | assert isinstance(t, (list, tuple)) and len(t) == 2 and isinstance(t[0], str) and isinstance(t[1], int)
15 |
16 | # httpserver backend mode configs
17 | MJML_HTTPSERVERS = getattr(settings, 'MJML_HTTPSERVERS', [{
18 | 'URL': 'https://api.mjml.io/v1/render',
19 | 'HTTP_AUTH': None, # None (default) or ('login', 'password')
20 | }])
21 | assert isinstance(MJML_HTTPSERVERS, (list, tuple))
22 | for t in MJML_HTTPSERVERS:
23 | assert isinstance(t, dict)
24 | assert 'URL' in t and isinstance(t['URL'], str)
25 | if 'HTTP_AUTH' in t:
26 | http_auth = t['HTTP_AUTH']
27 | assert isinstance(http_auth, (type(None), list, tuple))
28 | if http_auth is not None:
29 | assert len(http_auth) == 2 and isinstance(http_auth[0], str) and isinstance(http_auth[1], str)
30 |
--------------------------------------------------------------------------------
/mjml/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liminspace/django-mjml/51bc53ed37595cc64ea5102063a6ba332c712e35/mjml/templatetags/__init__.py
--------------------------------------------------------------------------------
/mjml/templatetags/mjml.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | from mjml.tools import mjml_render
4 |
5 | register = template.Library()
6 |
7 |
8 | class MJMLRenderNode(template.Node):
9 | def __init__(self, nodelist):
10 | self.nodelist = nodelist
11 |
12 | def render(self, context) -> str:
13 | mjml_source = self.nodelist.render(context)
14 | return mjml_render(mjml_source)
15 |
16 |
17 | @register.tag
18 | def mjml(parser, token) -> MJMLRenderNode:
19 | """
20 | Compile MJML template after render django template.
21 |
22 | Usage:
23 | {% mjml %}
24 | .. MJML template code ..
25 | {% endmjml %}
26 | """
27 | nodelist = parser.parse(('endmjml',))
28 | parser.delete_first_token()
29 | tokens = token.split_contents()
30 | if len(tokens) != 1:
31 | raise template.TemplateSyntaxError("'%r' tag doesn't receive any arguments." % tokens[0])
32 | return MJMLRenderNode(nodelist)
33 |
--------------------------------------------------------------------------------
/mjml/tools.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import json
3 | import random
4 | import socket
5 | import subprocess
6 | import tempfile
7 | from typing import Optional, Dict, List
8 |
9 | from django.utils.encoding import force_str, force_bytes
10 |
11 | from mjml import settings as mjml_settings
12 |
13 | _cache = {}
14 |
15 |
16 | def _mjml_render_by_cmd(mjml_code: str) -> str:
17 | if 'cmd_args' not in _cache:
18 | cmd_args = copy.copy(mjml_settings.MJML_EXEC_CMD)
19 | if not isinstance(cmd_args, list):
20 | cmd_args = [cmd_args]
21 | for ca in ('-i', '-s'):
22 | if ca not in cmd_args:
23 | cmd_args.append(ca)
24 | _cache['cmd_args'] = cmd_args
25 | else:
26 | cmd_args = _cache['cmd_args']
27 |
28 | with tempfile.SpooledTemporaryFile(max_size=(5 * 1024 * 1024)) as stdout_tmp_f:
29 | try:
30 | p = subprocess.Popen(cmd_args, stdin=subprocess.PIPE, stdout=stdout_tmp_f, stderr=subprocess.PIPE)
31 | stderr = p.communicate(force_bytes(mjml_code))[1]
32 | except (IOError, OSError) as e:
33 | cmd_str = ' '.join(cmd_args)
34 | raise RuntimeError(
35 | f'Problem to run command "{cmd_str}"\n'
36 | f'{e}\n'
37 | 'Check that mjml is installed and allow permissions to execute.\n'
38 | 'See https://github.com/mjmlio/mjml#installation'
39 | ) from e
40 | stdout_tmp_f.seek(0)
41 | stdout = stdout_tmp_f.read()
42 |
43 | if stderr:
44 | raise RuntimeError(f'MJML stderr is not empty: {force_str(stderr)}.')
45 |
46 | return force_str(stdout)
47 |
48 |
49 | def socket_recvall(sock: socket.socket, n: int) -> Optional[bytes]:
50 | data = b''
51 | while len(data) < n:
52 | packet = sock.recv(n - len(data))
53 | if not packet:
54 | return
55 | data += packet
56 | return data
57 |
58 |
59 | def _mjml_render_by_tcpserver(mjml_code: str) -> str:
60 | if len(mjml_settings.MJML_TCPSERVERS) > 1:
61 | servers = list(mjml_settings.MJML_TCPSERVERS)[:]
62 | random.shuffle(servers)
63 | else:
64 | servers = mjml_settings.MJML_TCPSERVERS
65 | mjml_code_data = force_bytes(mjml_code)
66 | mjml_code_data = force_bytes('{:09d}'.format(len(mjml_code_data))) + mjml_code_data
67 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
68 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
69 | s.settimeout(25)
70 | timeouts = 0
71 | for host, port in servers:
72 | try:
73 | s.connect((host, port))
74 | except socket.timeout:
75 | timeouts += 1
76 | continue
77 | except socket.error:
78 | continue
79 | try:
80 | s.sendall(mjml_code_data)
81 | ok = force_str(socket_recvall(s, 1)) == '0'
82 | a = force_str(socket_recvall(s, 9))
83 | result_len = int(a)
84 | result = force_str(socket_recvall(s, result_len))
85 | if ok:
86 | return result
87 | else:
88 | raise RuntimeError(f'MJML compile error (via MJML TCP server): {result}')
89 | except socket.timeout:
90 | timeouts += 1
91 | finally:
92 | s.close()
93 | raise RuntimeError(
94 | 'MJML compile error (via MJML TCP server): no working server\n'
95 | f'Number of servers: {len(servers)}\n'
96 | f'Timeouts: {timeouts}'
97 | )
98 |
99 |
100 | def _mjml_render_by_httpserver(mjml_code: str) -> str:
101 | import requests.auth
102 |
103 | if len(mjml_settings.MJML_HTTPSERVERS) > 1:
104 | servers = list(mjml_settings.MJML_HTTPSERVERS)[:]
105 | random.shuffle(servers)
106 | else:
107 | servers = mjml_settings.MJML_HTTPSERVERS
108 |
109 | timeouts = 0
110 | for server_conf in servers:
111 | http_auth = server_conf.get('HTTP_AUTH')
112 | auth = requests.auth.HTTPBasicAuth(*http_auth) if http_auth else None
113 |
114 | try:
115 | response = requests.post(
116 | url=server_conf['URL'],
117 | auth=auth,
118 | data=force_bytes(json.dumps({'mjml': mjml_code})),
119 | headers={'Content-Type': 'application/json'},
120 | timeout=25,
121 | )
122 | except requests.exceptions.Timeout:
123 | timeouts += 1
124 | continue
125 |
126 | try:
127 | data = response.json()
128 | except (TypeError, json.JSONDecodeError):
129 | data = {}
130 |
131 | if response.status_code == 200:
132 | errors: Optional[List[Dict]] = data.get('errors')
133 | if errors:
134 | msg_lines = [
135 | f'Line: {e.get("line")} Tag: {e.get("tagName")} Message: {e.get("message")}'
136 | for e in errors
137 | ]
138 | msg_str = '\n'.join(msg_lines)
139 | raise RuntimeError(f'MJML compile error (via MJML HTTP server): {msg_str}')
140 |
141 | return force_str(data['html'])
142 | else:
143 | msg = (
144 | f"[code={response.status_code}, request_id={data.get('request_id', '')}] "
145 | f"{data.get('message', 'Unknown error.')}"
146 | )
147 | raise RuntimeError(f'MJML compile error (via MJML HTTP server): {msg}')
148 |
149 | raise RuntimeError(
150 | 'MJML compile error (via MJML HTTP server): no working server\n'
151 | f'Number of servers: {len(servers)}\n'
152 | f'Timeouts: {timeouts}'
153 | )
154 |
155 |
156 | def mjml_render(mjml_source: str) -> str:
157 | if mjml_settings.MJML_BACKEND_MODE == 'cmd':
158 | return _mjml_render_by_cmd(mjml_source)
159 | elif mjml_settings.MJML_BACKEND_MODE == 'tcpserver':
160 | return _mjml_render_by_tcpserver(mjml_source)
161 | elif mjml_settings.MJML_BACKEND_MODE == 'httpserver':
162 | return _mjml_render_by_httpserver(mjml_source)
163 | raise RuntimeError(f'Invalid settings.MJML_BACKEND_MODE "{mjml_settings.MJML_BACKEND_MODE}"')
164 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | wheel==0.37.1
2 | twine==3.8.0
3 | coverage==6.2
4 | django>=2.2,<5.3
5 | requests>=2.24.0,<2.29.0
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | from setuptools import setup, find_packages
3 | import mjml
4 |
5 |
6 | setup(
7 | name='django-mjml',
8 | version=mjml.__version__,
9 | description='Use MJML in Django templates',
10 | long_description=open(os.path.join(os.path.dirname(__file__), 'README.rst')).read(),
11 | license='MIT',
12 | author='Igor Melnyk @liminspace',
13 | author_email='liminspace@gmail.com',
14 | url='https://github.com/liminspace/django-mjml',
15 | packages=find_packages(exclude=('testprj', 'testprj.*')),
16 | include_package_data=True,
17 | zip_safe=False, # because include static
18 | platforms=['OS Independent'],
19 | python_requires='>=3.6',
20 | install_requires=[
21 | 'django >=2.2,<5.3',
22 | ],
23 | extras_require={
24 | 'requests': [
25 | 'requests >=2.24',
26 | ],
27 | },
28 | keywords=[
29 | 'django', 'mjml', 'django-mjml', 'email', 'layout', 'template', 'templatetag',
30 | ],
31 | classifiers=[
32 | 'Development Status :: 5 - Production/Stable',
33 | 'Environment :: Web Environment',
34 | 'Programming Language :: Python',
35 | 'Programming Language :: Python :: 3',
36 | 'Programming Language :: Python :: 3.6',
37 | 'Programming Language :: Python :: 3.7',
38 | 'Programming Language :: Python :: 3.8',
39 | 'Programming Language :: Python :: 3.9',
40 | 'Programming Language :: Python :: 3.10',
41 | 'Programming Language :: Python :: 3.11',
42 | 'Programming Language :: Python :: 3.12',
43 | 'Programming Language :: Python :: 3.13',
44 | 'Intended Audience :: Developers',
45 | 'Topic :: Software Development :: Libraries',
46 | 'Topic :: Software Development :: Libraries :: Application Frameworks',
47 | 'Topic :: Software Development :: Libraries :: Python Modules',
48 | 'Framework :: Django',
49 | 'License :: OSI Approved :: MIT License',
50 | ],
51 | )
52 |
--------------------------------------------------------------------------------
/testprj/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/liminspace/django-mjml/51bc53ed37595cc64ea5102063a6ba332c712e35/testprj/__init__.py
--------------------------------------------------------------------------------
/testprj/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | BASE_DIR = os.path.abspath(os.path.dirname(__file__))
4 |
5 | SECRET_KEY = 'test'
6 |
7 | DEBUG = True
8 |
9 | ALLOWED_HOSTS = ['*']
10 |
11 | INSTALLED_APPS = (
12 | 'mjml',
13 | 'testprj',
14 | )
15 |
16 | MIDDLEWARE_CLASSES = ()
17 |
18 | ROOT_URLCONF = 'testprj.urls'
19 |
20 | WSGI_APPLICATION = 'testprj.wsgi.application'
21 |
22 | DATABASES = {
23 | 'default': {
24 | 'ENGINE': 'django.db.backends.sqlite3',
25 | 'NAME': ':memory:',
26 | },
27 | }
28 |
29 | LANGUAGE_CODE = 'en-us'
30 |
31 | TIME_ZONE = 'UTC'
32 |
33 | USE_I18N = True
34 |
35 | USE_L10N = True
36 |
37 | USE_TZ = True
38 |
39 | TEMPLATES = [
40 | {
41 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
42 | 'APP_DIRS': True,
43 | 'OPTIONS': {
44 | 'context_processors': (
45 | 'django.template.context_processors.request',
46 | ),
47 | },
48 | },
49 | ]
50 |
51 | MJML_BACKEND = 'cmd'
52 | MJML_EXEC_CMD = os.path.join(os.path.dirname(BASE_DIR), 'node_modules', '.bin', 'mjml')
53 | MJML_TCPSERVERS = (
54 | ('127.0.0.1', 28101),
55 | ('127.0.0.1', 28102),
56 | ('127.0.0.1', 28103),
57 | )
58 | MJML_HTTPSERVERS = (
59 | {
60 | 'URL': 'http://127.0.0.1:38101/v1/render',
61 | },
62 | {
63 | 'URL': 'http://127.0.0.1:38102/v1/render',
64 | },
65 | )
66 |
67 | DEFAULT_MJML_VERSION = 4
68 |
--------------------------------------------------------------------------------
/testprj/tests.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ImproperlyConfigured
2 | from django.template import TemplateSyntaxError
3 | from django.test import TestCase
4 |
5 | from mjml import settings as mjml_settings
6 | from mjml.apps import check_mjml_command
7 | from testprj.tools import safe_change_mjml_settings, render_tpl, MJMLFixtures
8 |
9 |
10 | class TestMJMLApps(TestCase):
11 | def test_check_mjml_command(self) -> None:
12 | with safe_change_mjml_settings():
13 | mjml_settings.MJML_EXEC_CMD = '/no_mjml_exec_test'
14 | with self.assertRaises(ImproperlyConfigured):
15 | check_mjml_command()
16 |
17 | mjml_settings.MJML_EXEC_CMD = ['python', '-c', 'print("wrong result for testing")', '-']
18 | with self.assertRaises(ImproperlyConfigured):
19 | check_mjml_command()
20 |
21 |
22 | class TestMJMLTemplatetag(MJMLFixtures, TestCase):
23 | def test_simple(self) -> None:
24 | html = render_tpl(self.TPLS['simple'])
25 | self.assertIn(' None:
32 | context = {
33 | 'title': 'Test title',
34 | 'title_size': '20px',
35 | 'btn_label': 'Test button',
36 | 'btn_color': '#ffcc00'
37 | }
38 | html = render_tpl("""
39 | {% mjml %}
40 |
41 |
42 |
43 |
44 |
45 |
46 | {{ title }}
47 |
48 |
49 |
50 |
51 | {{ btn_label }}
52 |
53 |
54 |
55 |
56 |
57 | {% endmjml %}
58 | """, context)
59 | self.assertIn(' None:
65 | items = ['test one', 'test two', 'test three']
66 | context = {
67 | 'items': items,
68 | }
69 | html = render_tpl("""
70 | {% mjml %}
71 |
72 |
73 |
74 |
75 |
76 |
77 | Test title
78 |
79 |
80 |
81 |
82 | {# test_comment $}
83 | {% for item in items %}
84 | {{ item }}
85 | {% endfor %}
86 | Test button
87 |
88 |
89 |
90 |
91 |
92 | {% endmjml %}
93 | """, context)
94 | self.assertIn(' None:
101 | with self.assertRaises(TemplateSyntaxError):
102 | render_tpl("""
103 | {% mjml "var"%}
104 |
105 | {% endmjml %}
106 | """)
107 |
108 | with self.assertRaises(TemplateSyntaxError):
109 | render_tpl("""
110 | {% mjml var %}
111 |
112 | {% endmjml %}
113 | """, {'var': 'test'})
114 |
115 | def test_unicode(self) -> None:
116 | html = render_tpl(self.TPLS['with_text_context_and_unicode'], {
117 | 'text': self.TEXTS['unicode'],
118 | })
119 | self.assertIn(' None:
8 | big_text = '[START]' + ('Big text. ' * 820 * 1024) + '[END]'
9 | html = render_tpl(self.TPLS['with_text_context'], {'text': big_text})
10 | self.assertIn('', html)
16 | self.assertIn('', html)
17 |
18 | def test_unicode(self) -> None:
19 | smile = '\u263a'
20 | checkmark = '\u2713'
21 | candy = '\U0001f36d'
22 | unicode_text = smile + checkmark + candy
23 | html = render_tpl(self.TPLS['with_text_context_and_unicode'], {'text': unicode_text})
24 | self.assertIn(' None:
19 | cls._settings_manager = safe_change_mjml_settings()
20 | cls._settings_manager.__enter__()
21 | mjml_settings.MJML_BACKEND_MODE = cls.SERVER_TYPE
22 | super().setUpClass()
23 |
24 | @classmethod
25 | def tearDownClass(cls) -> None:
26 | super().tearDownClass()
27 | cls._settings_manager.__exit__(None, None, None)
28 |
29 | def test_simple(self) -> None:
30 | html = render_tpl(self.TPLS['simple'])
31 | self.assertIn(' None:
38 | html = render_tpl(self.TPLS['with_text_context'], {
39 | 'text': '[START]' + ('1 2 3 4 5 6 7 8 9 0 ' * 410 * 1024) + '[END]',
40 | })
41 | self.assertIn(' None:
47 | html = render_tpl(self.TPLS['with_text_context_and_unicode'], {
48 | 'text': self.TEXTS['unicode'],
49 | })
50 | self.assertIn(' None:
57 | with self.assertRaises(RuntimeError) as cm:
58 | render_tpl("""
59 | {% mjml %}
60 |
61 |
62 |
63 |
64 |
65 | {% endmjml %}
66 | """)
67 | self.assertIn(' Tag: mj-button Message: mj-button ', str(cm.exception))
68 |
69 | @mock.patch('requests.post')
70 | def test_http_auth(self, post_mock) -> None:
71 | with safe_change_mjml_settings():
72 | for server_conf in mjml_settings.MJML_HTTPSERVERS:
73 | server_conf['HTTP_AUTH'] = ('testuser', 'testpassword')
74 |
75 | response = requests.Response()
76 | response.status_code = 200
77 | response._content = force_bytes(json.dumps({
78 | 'errors': [],
79 | 'html': 'html_string',
80 | 'mjml': 'mjml_string',
81 | 'mjml_version': '4.5.1',
82 | }))
83 | response.encoding = 'utf-8'
84 | response.headers['Content-Type'] = 'text/html; charset=utf-8'
85 | response.headers['Content-Length'] = len(response._content)
86 | post_mock.return_value = response
87 |
88 | render_tpl(self.TPLS['simple'])
89 |
90 | self.assertTrue(post_mock.called)
91 | self.assertIn('auth', post_mock.call_args[1])
92 | self.assertIsInstance(post_mock.call_args[1]['auth'], requests.auth.HTTPBasicAuth)
93 | self.assertEqual(post_mock.call_args[1]['auth'].username, 'testuser')
94 | self.assertEqual(post_mock.call_args[1]['auth'].password, 'testpassword')
95 |
96 | @unittest.skip('to run locally')
97 | def test_public_api(self) -> None:
98 | with safe_change_mjml_settings():
99 | mjml_settings.MJML_HTTPSERVERS = (
100 | {
101 | 'URL': 'https://api.mjml.io/v1/render',
102 | 'HTTP_AUTH': ('****', '****'),
103 | },
104 | )
105 | html = render_tpl(self.TPLS['with_text_context_and_unicode'], {
106 | 'text': self.TEXTS['unicode'] + ' [START]' + ('1 2 3 4 5 6 7 8 9 0 ' * 1024) + '[END]',
107 | })
108 | self.assertIn('
120 |
121 |
122 |
123 |
124 | {% endmjml %}
125 | """)
126 | self.assertIn(' Tag: mj-button Message: mj-button ', str(cm.exception))
127 |
--------------------------------------------------------------------------------
/testprj/tests_tcpserver.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from mjml import settings as mjml_settings
4 | from testprj.tools import safe_change_mjml_settings, MJMLServers, MJMLFixtures, render_tpl
5 |
6 |
7 | class TestMJMLTCPServer(MJMLFixtures, MJMLServers, TestCase):
8 | SERVER_TYPE = 'tcpserver'
9 | _settings_manager = None
10 |
11 | @classmethod
12 | def setUpClass(cls) -> None:
13 | cls._settings_manager = safe_change_mjml_settings()
14 | cls._settings_manager.__enter__()
15 | mjml_settings.MJML_BACKEND_MODE = cls.SERVER_TYPE
16 | super().setUpClass()
17 |
18 | @classmethod
19 | def tearDownClass(cls) -> None:
20 | super().tearDownClass()
21 | cls._settings_manager.__exit__(None, None, None)
22 |
23 | def test_simple(self) -> None:
24 | html = render_tpl(self.TPLS['simple'])
25 | self.assertIn(' None:
39 | html = render_tpl(self.TPLS['with_text_context'], {
40 | 'text': '[START]' + ('1 2 3 4 5 6 7 8 9 0 ' * 410 * 1024) + '[END]',
41 | })
42 | self.assertIn(' None:
48 | html = render_tpl(self.TPLS['with_text_context_and_unicode'], {
49 | 'text': self.TEXTS['unicode'],
50 | })
51 | self.assertIn(' int:
17 | env_ver = os.environ.get('MJML_VERSION', None)
18 | if env_ver:
19 | with suppress(ValueError, TypeError, IndexError):
20 | return int(env_ver.split('.')[0])
21 |
22 | return settings.DEFAULT_MJML_VERSION
23 |
24 |
25 | @contextmanager
26 | def safe_change_mjml_settings():
27 | """
28 | with safe_change_mjml_settings():
29 | mjml_settins.MJML_EXEC_PATH = 'other value'
30 | ...
31 | # mjml settings will be restored
32 | ...
33 | """
34 | settings_bak = {}
35 | for k, v in mjml_settings.__dict__.items():
36 | if k[:5] == 'MJML_':
37 | settings_bak[k] = copy.deepcopy(v)
38 | tools._cache.clear()
39 | try:
40 | yield
41 | finally:
42 | for k, v in settings_bak.items():
43 | setattr(mjml_settings, k, v)
44 | tools._cache.clear()
45 |
46 |
47 | def render_tpl(tpl: str, context: Optional[Dict[str, Any]] = None) -> str:
48 | if get_mjml_version() >= 4:
49 | tpl = tpl.replace('', '').replace('', '')
50 | return Template('{% load mjml %}' + tpl).render(Context(context))
51 |
52 |
53 | class MJMLServers:
54 | SERVER_TYPE = NotImplemented # tcpserver, httpserver
55 | _processes = []
56 |
57 | @classmethod
58 | def _terminate_processes(cls) -> None:
59 | while cls._processes:
60 | p = cls._processes.pop()
61 | p.terminate()
62 |
63 | @classmethod
64 | def _start_tcp_servers(cls) -> None:
65 | root_dir = os.path.dirname(settings.BASE_DIR)
66 | tcpserver_path = os.path.join(root_dir, 'mjml-tcpserver', 'tcpserver.js')
67 | env = os.environ.copy()
68 | env['NODE_PATH'] = root_dir
69 | for host, port in mjml_settings.MJML_TCPSERVERS:
70 | p = subprocess.Popen([
71 | 'node',
72 | tcpserver_path,
73 | f'--port={port}',
74 | f'--host={host}',
75 | ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
76 | cls._processes.append(p)
77 | time.sleep(5)
78 |
79 | @classmethod
80 | def _stop_tcp_servers(cls) -> None:
81 | cls._terminate_processes()
82 |
83 | @classmethod
84 | def _start_http_servers(cls) -> None:
85 | env = os.environ.copy()
86 | for server_conf in mjml_settings.MJML_HTTPSERVERS:
87 | parsed = urlparse(server_conf['URL'])
88 | host, port = parsed.netloc.split(':')
89 | p = subprocess.Popen([
90 | 'mjml-http-server',
91 | f'--host={host}',
92 | f'--port={port}',
93 | '--max-body=8500kb',
94 | ], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env)
95 | cls._processes.append(p)
96 | time.sleep(5)
97 |
98 | @classmethod
99 | def _stop_http_servers(cls) -> None:
100 | cls._terminate_processes()
101 |
102 | @classmethod
103 | def setUpClass(cls) -> None:
104 | super().setUpClass()
105 | if cls.SERVER_TYPE == 'tcpserver':
106 | cls._start_tcp_servers()
107 | elif cls.SERVER_TYPE == 'httpserver':
108 | cls._start_http_servers()
109 | else:
110 | raise RuntimeError('Invalid SERVER_TYPE: {}', cls.SERVER_TYPE)
111 |
112 | @classmethod
113 | def tearDownClass(cls) -> None:
114 | if cls.SERVER_TYPE == 'tcpserver':
115 | cls._stop_tcp_servers()
116 | elif cls.SERVER_TYPE == 'httpserver':
117 | cls._stop_http_servers()
118 | else:
119 | raise RuntimeError('Invalid SERVER_TYPE: {}', cls.SERVER_TYPE)
120 | super().tearDownClass()
121 |
122 |
123 | class MJMLFixtures:
124 | TPLS = {
125 | 'simple': """
126 | {% mjml %}
127 |
128 |
129 |
130 |
131 |
132 |
133 | Test title
134 |
135 |
136 |
137 |
138 | Test button
139 |
140 |
141 |
142 |
143 |
144 | {% endmjml %}
145 | """,
146 | 'with_text_context': """
147 | {% mjml %}
148 |
149 |
150 |
151 |
152 |
153 | {{ text }}
154 |
155 |
156 |
157 |
158 |
159 | {% endmjml %}
160 | """,
161 | 'with_text_context_and_unicode': """
162 | {% mjml %}
163 |
164 |
165 |
166 |
167 |
168 | Український текст {{ text }} ©
169 |
170 |
171 |
172 |
173 |
174 | {% endmjml %}
175 | """,
176 | }
177 | SYMBOLS = {
178 | 'smile': '\u263a',
179 | 'checkmark': '\u2713',
180 | 'candy': '\U0001f36d', # b'\xf0\x9f\x8d\xad'.decode('utf-8')
181 | }
182 | TEXTS = {
183 | 'unicode': SYMBOLS['smile'] + SYMBOLS['checkmark'] + SYMBOLS['candy'],
184 | }
185 |
--------------------------------------------------------------------------------
/testprj/urls.py:
--------------------------------------------------------------------------------
1 | urlpatterns = []
2 |
--------------------------------------------------------------------------------
/testprj/wsgi.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 |
4 | sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testprj.settings')
6 | from django.core.wsgi import get_wsgi_application
7 |
8 | application = get_wsgi_application()
9 |
--------------------------------------------------------------------------------
/tools.py:
--------------------------------------------------------------------------------
1 | import os
2 | import sys
3 | import subprocess
4 | import shutil
5 |
6 |
7 | COMMANDS_LIST = ('testmanage', 'test', 'release')
8 | COMMANDS_INFO = {
9 | 'testmanage': 'run manage for test project',
10 | 'test': 'run tests (eq. "testmanage test")',
11 | 'release': 'make distributive and upload to pypi (setup.py bdist_wheel upload)'
12 | }
13 |
14 |
15 | def testmanage(*args):
16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testprj.settings")
17 | sys.path.append(os.path.dirname(os.path.abspath(__file__)))
18 | sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testprj'))
19 | from django.core.management import execute_from_command_line
20 | execute_from_command_line(['manage.py'] + list(args))
21 |
22 |
23 | def test(*args):
24 | testmanage('test', *args)
25 |
26 |
27 | def release(*args):
28 | root_dir = os.path.dirname(os.path.abspath(__file__))
29 | shutil.rmtree(os.path.join(root_dir, 'build'), ignore_errors=True)
30 | shutil.rmtree(os.path.join(root_dir, 'dist'), ignore_errors=True)
31 | shutil.rmtree(os.path.join(root_dir, 'django_mjml.egg-info'), ignore_errors=True)
32 | subprocess.call(['python', 'setup.py', 'sdist', 'bdist_wheel'])
33 | subprocess.call(['twine', 'upload', 'dist/*'])
34 |
35 |
36 | if __name__ == '__main__':
37 | if len(sys.argv) > 1 and sys.argv[1] in COMMANDS_LIST:
38 | locals()[sys.argv[1]](*sys.argv[2:])
39 | else:
40 | print('Available commands:')
41 | for c in COMMANDS_LIST:
42 | print(c + ' - ' + COMMANDS_INFO[c])
43 |
--------------------------------------------------------------------------------