├── .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 | --------------------------------------------------------------------------------