✨(domains) notify support when domain status changes
During the scheduled task to check domains, send an email notification to domain support if a status has changed.
This commit is contained in:
@@ -10,6 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(domains) notify support when domain status changes #668
|
||||||
- ✨(domains) define domain check interval as a settings
|
- ✨(domains) define domain check interval as a settings
|
||||||
- ✨(oidc) add simple introspection backend #832
|
- ✨(oidc) add simple introspection backend #832
|
||||||
- 🧑💻(tasks) run management commands #814
|
- 🧑💻(tasks) run management commands #814
|
||||||
|
|||||||
Binary file not shown.
@@ -2,12 +2,17 @@
|
|||||||
Declare and configure the models for the People additional application : mailbox_manager
|
Declare and configure the models for the People additional application : mailbox_manager
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.core import exceptions, validators
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core import exceptions, mail, validators
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import get_language, gettext, override
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import BaseInvitation, BaseModel, Organization, User
|
from core.models import BaseInvitation, BaseModel, Organization, User
|
||||||
@@ -18,6 +23,28 @@ from mailbox_manager.enums import (
|
|||||||
MailDomainStatusChoices,
|
MailDomainStatusChoices,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_NOTIFICATION_MAILS = {
|
||||||
|
# new status domain: (mail subject, mail template html, mail template text)
|
||||||
|
MailDomainStatusChoices.ENABLED: (
|
||||||
|
_("[La Suite] Your domain is ready"),
|
||||||
|
"mail/html/maildomain_enabled.html",
|
||||||
|
"mail/text/maildomain_enabled.txt",
|
||||||
|
),
|
||||||
|
MailDomainStatusChoices.ACTION_REQUIRED: (
|
||||||
|
_("[La Suite] Your domain requires action"),
|
||||||
|
"mail/html/maildomain_action_required.html",
|
||||||
|
"mail/text/maildomain_action_required.txt",
|
||||||
|
),
|
||||||
|
MailDomainStatusChoices.FAILED: (
|
||||||
|
_("[La Suite] Your domain has failed"),
|
||||||
|
"mail/html/maildomain_failed.html",
|
||||||
|
"mail/text/maildomain_failed.txt",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class MailDomain(BaseModel):
|
class MailDomain(BaseModel):
|
||||||
"""Domain names from which we will create email addresses (mailboxes)."""
|
"""Domain names from which we will create email addresses (mailboxes)."""
|
||||||
@@ -104,6 +131,45 @@ class MailDomain(BaseModel):
|
|||||||
bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED
|
bool(self.organization) and self.status == MailDomainStatusChoices.ENABLED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def notify_status_change(self, recipients=None, language=None):
|
||||||
|
"""
|
||||||
|
Notify the support team that the domain status has changed.
|
||||||
|
"""
|
||||||
|
subject, template_html, template_text = STATUS_NOTIFICATION_MAILS.get(
|
||||||
|
self.status, (None, None, None)
|
||||||
|
)
|
||||||
|
if not subject:
|
||||||
|
return
|
||||||
|
context = {
|
||||||
|
"title": subject,
|
||||||
|
"domain_name": self.name,
|
||||||
|
"manage_domain_url": (
|
||||||
|
f"{Site.objects.get_current().domain}/mail-domains/{self.slug}/"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with override(language or get_language()):
|
||||||
|
mail.send_mail(
|
||||||
|
subject,
|
||||||
|
render_to_string(template_text, context),
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
recipients or [self.support_email],
|
||||||
|
html_message=render_to_string(template_html, context),
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except smtplib.SMTPException as exception:
|
||||||
|
logger.error(
|
||||||
|
"Notification email to %s was not sent: %s",
|
||||||
|
self.support_email,
|
||||||
|
exception,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Information about domain %s sent to %s.",
|
||||||
|
self.name,
|
||||||
|
self.support_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MailDomainAccess(BaseModel):
|
class MailDomainAccess(BaseModel):
|
||||||
"""Allow to manage users' accesses to mail domains."""
|
"""Allow to manage users' accesses to mail domains."""
|
||||||
|
|||||||
@@ -63,5 +63,6 @@ def fetch_domains_status_task(status: str):
|
|||||||
logger.error("Failed to fetch status for domain %s: %s", domain.name, err)
|
logger.error("Failed to fetch status for domain %s: %s", domain.name, err)
|
||||||
else:
|
else:
|
||||||
if old_status != domain.status:
|
if old_status != domain.status:
|
||||||
|
domain.notify_status_change()
|
||||||
changed_domains.append(f"{domain.name} ({domain.status})")
|
changed_domains.append(f"{domain.name} ({domain.status})")
|
||||||
return changed_domains
|
return changed_domains
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ Unit tests for mailbox manager tasks.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,7 +16,11 @@ import responses
|
|||||||
|
|
||||||
from mailbox_manager import enums, factories, tasks
|
from mailbox_manager import enums, factories, tasks
|
||||||
|
|
||||||
from .fixtures.dimail import CHECK_DOMAIN_BROKEN_INTERNAL, CHECK_DOMAIN_OK
|
from .fixtures.dimail import (
|
||||||
|
CHECK_DOMAIN_BROKEN_EXTERNAL,
|
||||||
|
CHECK_DOMAIN_BROKEN_INTERNAL,
|
||||||
|
CHECK_DOMAIN_OK,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -30,12 +38,18 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
|
|||||||
domain_failed = factories.MailDomainFactory(
|
domain_failed = factories.MailDomainFactory(
|
||||||
status=enums.MailDomainStatusChoices.FAILED
|
status=enums.MailDomainStatusChoices.FAILED
|
||||||
)
|
)
|
||||||
|
domain_pending = factories.MailDomainFactory(
|
||||||
|
status=enums.MailDomainStatusChoices.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
body_content_ok1 = CHECK_DOMAIN_OK.copy()
|
body_content_ok1 = CHECK_DOMAIN_OK.copy()
|
||||||
body_content_ok1["name"] = domain_enabled1.name
|
body_content_ok1["name"] = domain_enabled1.name
|
||||||
|
|
||||||
body_content_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
|
body_content_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
|
||||||
body_content_broken["name"] = domain_enabled2.name
|
body_content_broken_internal["name"] = domain_enabled2.name
|
||||||
|
|
||||||
|
body_content_broken_external = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
|
||||||
|
body_content_broken_external["name"] = domain_pending.name
|
||||||
|
|
||||||
body_content_ok2 = CHECK_DOMAIN_OK.copy()
|
body_content_ok2 = CHECK_DOMAIN_OK.copy()
|
||||||
body_content_ok2["name"] = domain_disabled.name
|
body_content_ok2["name"] = domain_disabled.name
|
||||||
@@ -44,7 +58,8 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
|
|||||||
body_content_ok3["name"] = domain_failed.name
|
body_content_ok3["name"] = domain_failed.name
|
||||||
for domain, body_content in [
|
for domain, body_content in [
|
||||||
(domain_enabled1, body_content_ok1),
|
(domain_enabled1, body_content_ok1),
|
||||||
(domain_enabled2, body_content_broken),
|
(domain_enabled2, body_content_broken_internal),
|
||||||
|
(domain_pending, body_content_broken_external),
|
||||||
(domain_failed, body_content_ok3),
|
(domain_failed, body_content_ok3),
|
||||||
]:
|
]:
|
||||||
# Mock dimail API with success response
|
# Mock dimail API with success response
|
||||||
@@ -59,18 +74,20 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
|
|||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
re.compile(rf".*/domains/{domain_enabled2.name}/fix/"),
|
re.compile(rf".*/domains/{domain_enabled2.name}/fix/"),
|
||||||
body=json.dumps(body_content_broken),
|
body=json.dumps(body_content_broken_internal),
|
||||||
status=200,
|
status=200,
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.ENABLED)
|
with mock.patch("django.core.mail.send_mail") as mock_send:
|
||||||
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.FAILED)
|
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.ENABLED)
|
||||||
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.ACTION_REQUIRED)
|
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.FAILED)
|
||||||
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.PENDING)
|
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.ACTION_REQUIRED)
|
||||||
|
tasks.fetch_domains_status_task(enums.MailDomainStatusChoices.PENDING)
|
||||||
domain_enabled1.refresh_from_db()
|
domain_enabled1.refresh_from_db()
|
||||||
domain_enabled2.refresh_from_db()
|
domain_enabled2.refresh_from_db()
|
||||||
domain_disabled.refresh_from_db()
|
domain_disabled.refresh_from_db()
|
||||||
domain_failed.refresh_from_db()
|
domain_failed.refresh_from_db()
|
||||||
|
domain_pending.refresh_from_db()
|
||||||
# Nothing change for the first domain enable
|
# Nothing change for the first domain enable
|
||||||
assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED
|
assert domain_enabled1.status == enums.MailDomainStatusChoices.ENABLED
|
||||||
# Status of the second activated domain has changed to failure
|
# Status of the second activated domain has changed to failure
|
||||||
@@ -79,6 +96,69 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
|
|||||||
assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED
|
assert domain_failed.status == enums.MailDomainStatusChoices.ENABLED
|
||||||
# Disabled domain was excluded
|
# Disabled domain was excluded
|
||||||
assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED
|
assert domain_disabled.status == enums.MailDomainStatusChoices.DISABLED
|
||||||
|
# Pending domain has changed to action required
|
||||||
|
assert domain_pending.status == enums.MailDomainStatusChoices.ACTION_REQUIRED
|
||||||
|
|
||||||
|
# Check that the notification email was sent
|
||||||
|
assert mock_send.call_count == 3
|
||||||
|
domain_enabled2_context = {
|
||||||
|
"title": "[La Suite] Your domain has failed",
|
||||||
|
"domain_name": domain_enabled2.name,
|
||||||
|
"manage_domain_url": (
|
||||||
|
f"{Site.objects.get_current().domain}/mail-domains/{domain_enabled2.slug}/"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
domain_pending_context = {
|
||||||
|
"title": "[La Suite] Your domain requires action",
|
||||||
|
"domain_name": domain_pending.name,
|
||||||
|
"manage_domain_url": (
|
||||||
|
f"{Site.objects.get_current().domain}/mail-domains/{domain_pending.slug}/"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
domain_failed_context = {
|
||||||
|
"title": "[La Suite] Your domain is ready",
|
||||||
|
"domain_name": domain_failed.name,
|
||||||
|
"manage_domain_url": (
|
||||||
|
f"{Site.objects.get_current().domain}/mail-domains/{domain_failed.slug}/"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
calls = [
|
||||||
|
mock.call(
|
||||||
|
"[La Suite] Your domain has failed",
|
||||||
|
render_to_string(
|
||||||
|
"mail/text/maildomain_failed.txt", domain_enabled2_context
|
||||||
|
),
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
[domain_enabled2.support_email],
|
||||||
|
html_message=render_to_string(
|
||||||
|
"mail/html/maildomain_failed.html", domain_enabled2_context
|
||||||
|
),
|
||||||
|
fail_silently=False,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
"[La Suite] Your domain requires action",
|
||||||
|
render_to_string(
|
||||||
|
"mail/text/maildomain_action_required.txt", domain_pending_context
|
||||||
|
),
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
[domain_pending.support_email],
|
||||||
|
html_message=render_to_string(
|
||||||
|
"mail/html/maildomain_action_required.html", domain_pending_context
|
||||||
|
),
|
||||||
|
fail_silently=False,
|
||||||
|
),
|
||||||
|
mock.call(
|
||||||
|
"[La Suite] Your domain is ready",
|
||||||
|
render_to_string("mail/text/maildomain_enabled.txt", domain_failed_context),
|
||||||
|
settings.EMAIL_FROM,
|
||||||
|
[domain_failed.support_email],
|
||||||
|
html_message=render_to_string(
|
||||||
|
"mail/html/maildomain_enabled.html", domain_failed_context
|
||||||
|
),
|
||||||
|
fail_silently=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
mock_send.assert_has_calls(calls, any_order=True)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(MAIL_CHECK_DOMAIN_INTERVAL=0)
|
@override_settings(MAIL_CHECK_DOMAIN_INTERVAL=0)
|
||||||
|
|||||||
45
src/mail/mjml/maildomain_action_required.mjml
Normal file
45
src/mail/mjml/maildomain_action_required.mjml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
|
||||||
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<mj-wrapper css-class="wrapper" padding="10px">
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-image align="center" src="{% base64_static 'images/logo-laregie.png' %}" width="157px" align="left" alt="La Régie" padding="10px" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section mj-class="bg--white-100">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" padding="10px 20px" />
|
||||||
|
<mj-text>
|
||||||
|
<strong>
|
||||||
|
{% trans "Some actions are required on your domain" %}
|
||||||
|
</strong>
|
||||||
|
</mj-text>
|
||||||
|
<!-- Main Message -->
|
||||||
|
<mj-text>{% trans "Hello," %}</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
{% blocktranslate with name=domain_name trimmed %}
|
||||||
|
Your domain <b>{{ name }}</b> cannot be used until the required actions have been completed.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>{% trans "To solve this problem, please log in La Régie via ProConnect and follow instructions, by following this link:" %}</mj-text>
|
||||||
|
<mj-button href="//{{ manage_domain_url }}" background-color="#000091" color="white" padding-bottom="30px">
|
||||||
|
<img src="{% base64_static 'images/arrow.png' %}" width="25px" align="left" alt="arrow" background-color="red"/>
|
||||||
|
{% trans "Go to La Régie"%}
|
||||||
|
</mj-button>
|
||||||
|
<!-- end Main Message -->
|
||||||
|
<!-- Signature -->
|
||||||
|
<mj-text>
|
||||||
|
<p>{% trans "Regards," %}</p>
|
||||||
|
<p>{% trans "La Suite Team" %}</p>
|
||||||
|
</mj-text>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" />
|
||||||
|
<mj-image align="left" src="{% base64_static 'images/logo-footer-mail.png' %}" width="160px" align="left" alt="La Suite" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-wrapper>
|
||||||
|
</mj-body>
|
||||||
|
|
||||||
|
</mjml>
|
||||||
|
|
||||||
45
src/mail/mjml/maildomain_enabled.mjml
Normal file
45
src/mail/mjml/maildomain_enabled.mjml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
|
||||||
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<mj-wrapper css-class="wrapper" padding="10px">
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-image align="center" src="{% base64_static 'images/logo-laregie.png' %}" width="157px" align="left" alt="La Régie" padding="10px" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section mj-class="bg--white-100">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" padding="10px 20px" />
|
||||||
|
<mj-text>
|
||||||
|
<strong>
|
||||||
|
{% trans "Your domain is ready" %}
|
||||||
|
</strong>
|
||||||
|
</mj-text>
|
||||||
|
<!-- Main Message -->
|
||||||
|
<mj-text>{% trans "Hurray!" %}</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
{% blocktranslate with name=domain_name trimmed %}
|
||||||
|
Your domain <b>{{ name }}</b> can be used now.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>{% trans "To do so, please log in La Régie via ProConnect, by following this link:" %}</mj-text>
|
||||||
|
<mj-button href="//{{ manage_domain_url }}" background-color="#000091" color="white" padding-bottom="30px">
|
||||||
|
<img src="{% base64_static 'images/arrow.png' %}" width="25px" align="left" alt="arrow" background-color="red"/>
|
||||||
|
{% trans "Go to La Régie"%}
|
||||||
|
</mj-button>
|
||||||
|
<!-- end Main Message -->
|
||||||
|
<!-- Signature -->
|
||||||
|
<mj-text>
|
||||||
|
<p>{% trans "Regards," %}</p>
|
||||||
|
<p>{% trans "La Suite Team" %}</p>
|
||||||
|
</mj-text>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" />
|
||||||
|
<mj-image align="left" src="{% base64_static 'images/logo-footer-mail.png' %}" width="160px" align="left" alt="La Suite" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-wrapper>
|
||||||
|
</mj-body>
|
||||||
|
|
||||||
|
</mjml>
|
||||||
|
|
||||||
47
src/mail/mjml/maildomain_failed.mjml
Normal file
47
src/mail/mjml/maildomain_failed.mjml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-include path="./partial/header.mjml" />
|
||||||
|
|
||||||
|
<mj-body mj-class="bg--blue-100">
|
||||||
|
<mj-wrapper css-class="wrapper" padding="10px">
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-image align="center" src="{% base64_static 'images/logo-laregie.png' %}" width="157px" align="left" alt="La Régie" padding="10px" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
<mj-section mj-class="bg--white-100">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" padding="10px 20px" />
|
||||||
|
<mj-text>
|
||||||
|
<strong>
|
||||||
|
{% trans "Your domain has failed" %}
|
||||||
|
</strong>
|
||||||
|
</mj-text>
|
||||||
|
<!-- Main Message -->
|
||||||
|
<mj-text>{% trans "Hello," %}</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
{% blocktranslate with name=domain_name trimmed %}
|
||||||
|
The domain <b>{{ name }}</b> has encountered an error. As long as this error persists, all related mailboxes will remain disabled.
|
||||||
|
Technical support is currently working on the issue.
|
||||||
|
{% endblocktranslate %}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>{% trans "You can track the status of your domain on the management interface."%}
|
||||||
|
<br>{% trans "To do this, please log in to La Régie via ProConnect, by following this link:" %}</mj-text>
|
||||||
|
<mj-button href="//{{ manage_domain_url }}" background-color="#000091" color="white" padding-bottom="30px">
|
||||||
|
<img src="{% base64_static 'images/arrow.png' %}" width="25px" align="left" alt="arrow" background-color="red"/>
|
||||||
|
{% trans "Go to La Régie"%}
|
||||||
|
</mj-button>
|
||||||
|
<!-- end Main Message -->
|
||||||
|
<!-- Signature -->
|
||||||
|
<mj-text>
|
||||||
|
<p>{% trans "Regards," %}</p>
|
||||||
|
<p>{% trans "La Suite Team" %}</p>
|
||||||
|
</mj-text>
|
||||||
|
<mj-divider border-width="1px" border-style="solid" border-color="#EEEEEE" width="100%" />
|
||||||
|
<mj-image align="left" src="{% base64_static 'images/logo-footer-mail.png' %}" width="160px" align="left" alt="La Suite" />
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-wrapper>
|
||||||
|
</mj-body>
|
||||||
|
|
||||||
|
</mjml>
|
||||||
|
|
||||||
Reference in New Issue
Block a user