Email: one branded HTML layout with a text part, and invites can be emailed (#5917)
Each email was a subject and a plain-text f-string at its call site. mail_templates.py now holds one layout: the site name, a heading, the message, a button for the link and a footer saying who sent it and why. Every email (password reset, test, invite) is a function returning its subject, text and HTML. mailer.send sends them as multipart/alternative, text first. The HTML is email-shaped: tables, inline styles, no images or fonts, and every value from settings or a person escaped. POST /api/invites takes send_email. It is refused before anything is made when email is off or the invite names no address. The link is built from the public address. A failed send leaves the invite standing with email_error, so the admin still gets the link once. The invite form offers "Email the link to …" when email is on and an address is filled in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,8 @@ on a LAN.
|
||||
|
||||
To let someone else in, send them an invite: **Settings → People → Invites** makes a link that
|
||||
works once, expires (a week by default), and can be pinned to one email address. It
|
||||
lets that one person register while registration stays closed.
|
||||
lets that one person register while registration stays closed. With email set up (below) and an
|
||||
address filled in, **Create and email** sends the link to that address as well.
|
||||
|
||||
**2. Terminate TLS in front of it, and forward the scheme.** The app marks the
|
||||
session cookie `Secure` and sends HSTS only when it can tell the request arrived over
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { appLink } from "../router/links";
|
||||
@@ -7,6 +7,7 @@ import BaseButton from "./BaseButton.vue";
|
||||
import BaseInput from "./BaseInput.vue";
|
||||
import OneTimeSecret from "./OneTimeSecret.vue";
|
||||
import { toastOnFailure, useLoad } from "../composables/useAction";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
|
||||
// Admin: invite one person to register while registration stays closed (#5172).
|
||||
// The server keeps only a hash of each invite's token, so the link is shown once,
|
||||
@@ -38,17 +39,30 @@ const days = ref(7);
|
||||
const creating = ref(false);
|
||||
// The link for the invite just made. Never retrievable again once dismissed.
|
||||
const freshLink = ref("");
|
||||
const freshNote = ref("");
|
||||
|
||||
// Emailing the link (#5917) needs email set up and an address to send it to. The
|
||||
// server says whether email is on in the same flag the sign-in page reads.
|
||||
const config = useConfigStore();
|
||||
const sendEmail = ref(true);
|
||||
const canEmail = computed(() => config.passwordResetByEmail && email.value.trim() !== "");
|
||||
|
||||
async function create() {
|
||||
creating.value = true;
|
||||
error.value = "";
|
||||
freshLink.value = "";
|
||||
try {
|
||||
const res = await api.post<{ invite: Invite; token: string }>("/api/invites", {
|
||||
email: email.value.trim() || null,
|
||||
days: days.value,
|
||||
});
|
||||
const to = email.value.trim();
|
||||
const res = await api.post<{ invite: Invite; token: string; emailed?: boolean; email_error?: string }>(
|
||||
"/api/invites",
|
||||
{ email: to || null, days: days.value, send_email: canEmail.value && sendEmail.value },
|
||||
);
|
||||
freshLink.value = appLink({ name: "register", query: { invite: res.token } });
|
||||
freshNote.value = res.emailed
|
||||
? `Emailed to ${to}. The link is here too, in case it's easier to send yourself.`
|
||||
: res.email_error
|
||||
? `${res.email_error} Send this link to ${to} yourself.`
|
||||
: "Send this to the person you're inviting.";
|
||||
invites.value = [res.invite, ...invites.value];
|
||||
email.value = "";
|
||||
} catch (e) {
|
||||
@@ -100,7 +114,7 @@ onMounted(() => {
|
||||
<OneTimeSecret
|
||||
v-if="freshLink"
|
||||
:value="freshLink"
|
||||
note="Send this to the person you're inviting."
|
||||
:note="freshNote"
|
||||
@done="freshLink = ''"
|
||||
/>
|
||||
|
||||
@@ -123,7 +137,13 @@ onMounted(() => {
|
||||
<option v-for="l in LIFETIMES" :key="l.days" :value="l.days">{{ l.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<BaseButton type="submit" :loading="creating">Create invite</BaseButton>
|
||||
<BaseButton type="submit" :loading="creating">{{
|
||||
canEmail && sendEmail ? "Create and email" : "Create invite"
|
||||
}}</BaseButton>
|
||||
<label v-if="canEmail" class="flex w-full items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<input v-model="sendEmail" type="checkbox" class="h-4 w-4 accent-brand" />
|
||||
Email the link to {{ email.trim() }}
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
|
||||
@@ -11,16 +11,18 @@ from quart import Blueprint, g, jsonify, request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import aliased
|
||||
|
||||
from . import audit
|
||||
from . import audit, mail_templates
|
||||
from .auth import require_admin
|
||||
from .common import normalize_email
|
||||
from .db import session_scope
|
||||
from .invites import MAX_DAYS, lifetime_days, serialize
|
||||
from .mailer import mail_settings, send
|
||||
from .models.invite import Invite
|
||||
from .models.user import User
|
||||
from .proxy import client_address
|
||||
from .responses import json_error, not_found, parse_uuid
|
||||
from .security import generate_token, hash_token
|
||||
from .settings import get_setting
|
||||
|
||||
bp = Blueprint("invites", __name__, url_prefix="/api/invites")
|
||||
|
||||
@@ -38,10 +40,19 @@ async def create_invite():
|
||||
days = lifetime_days(data.get("days"))
|
||||
if days is None:
|
||||
return json_error(f"an invite lasts between 1 and {MAX_DAYS} days", 400)
|
||||
# Emailing the link needs someone to send it to and a way to send. Both are
|
||||
# checked BEFORE the invite exists, so a refusal leaves nothing half-made.
|
||||
emailing = data.get("send_email") is True
|
||||
if emailing and email is None:
|
||||
return json_error("to email an invite, give the address it's for", 400)
|
||||
|
||||
token = generate_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
async with session_scope() as db:
|
||||
cfg = await mail_settings(db) if emailing else None
|
||||
if emailing and cfg is None:
|
||||
return json_error("Email is off: set it up in Settings → Email first.", 400)
|
||||
site = await get_setting(db, "site_name")
|
||||
invite = Invite(
|
||||
token_hash=hash_token(token),
|
||||
created_by=g.user_id,
|
||||
@@ -59,9 +70,22 @@ async def create_invite():
|
||||
await audit.record(
|
||||
audit.INVITE_CREATED, user_id=g.user_id, email=admin.email, detail=f"for {email or 'anyone'}, {days} days"
|
||||
)
|
||||
body: dict = {"invite": serialize(invite, now), "token": token}
|
||||
if cfg is not None:
|
||||
# Built from the public address, never from the request (mail_templates).
|
||||
link = f"{cfg.public_url}/register?invite={token}"
|
||||
try:
|
||||
await send(cfg, email, mail_templates.invite(site, admin.display_name or admin.email, link, days))
|
||||
body["emailed"] = True
|
||||
logger.info("invite emailed id=%s to=%s", invite.id, email)
|
||||
except Exception as e: # any SMTP, socket or TLS failure; the admin needs its text
|
||||
# The invite stands either way: the link is still shown once, so the admin
|
||||
# can send it some other way.
|
||||
logger.warning("invite email failed id=%s: %s", invite.id, e)
|
||||
body["email_error"] = f"Couldn't send: {e}"
|
||||
# The token goes back exactly once. The client builds the link from it, since only
|
||||
# the browser knows the address people actually reach this server at.
|
||||
return jsonify({"invite": serialize(invite, now), "token": token}), 201
|
||||
return jsonify(body), 201
|
||||
|
||||
|
||||
@bp.get("")
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""What the server's emails say, and the one layout they all share (#5917).
|
||||
|
||||
Each email is a function returning an `Email`: a subject, a plain-text part and an
|
||||
HTML part. `mailer.send` sends both as one multipart/alternative message, so a client
|
||||
that shows HTML gets the branded version and one that doesn't still gets every word
|
||||
and the link.
|
||||
|
||||
The HTML is deliberately email-shaped rather than web-shaped. It uses tables and
|
||||
inline styles, because many mail clients drop `<style>` blocks and ignore flexbox.
|
||||
It uses no images or web fonts and fetches nothing: the app sends with no outbound
|
||||
network beyond the mail server (rule 164), and a remote image is a tracking pixel
|
||||
anyway. Everything taken from settings or from a person (the site name, an address,
|
||||
a link) is escaped before it goes into the HTML.
|
||||
|
||||
Links come from Settings → General → Public address, never from the request: a
|
||||
forged Host header would otherwise mail a stranger a link to the forger's site.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
|
||||
# The brand yellow (`brand` in tailwind.config.js) and the app's neutral greys. The
|
||||
# button text is near-black for the same reason it is in the app: white on this
|
||||
# yellow is unreadable.
|
||||
BRAND = "#F5C518"
|
||||
INK = "#171717"
|
||||
MUTED = "#737373"
|
||||
RULE = "#E5E5E5"
|
||||
PAGE = "#F5F5F5"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Email:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Button:
|
||||
label: str
|
||||
url: str
|
||||
|
||||
|
||||
def _render(
|
||||
site: str, subject: str, heading: str, lines: list[str], button: _Button | None, footer: str
|
||||
) -> Email:
|
||||
"""One email in the shared layout: the site's name, a heading, the message, a
|
||||
button for the link, and a footer saying who sent it and why (preference 194).
|
||||
|
||||
`lines` are paragraphs of plain text. The button goes after the first one, and the
|
||||
text part spells its link out in full, since that is the only way a text-only
|
||||
client can follow it.
|
||||
"""
|
||||
text_parts = [heading] + ([lines[0]] if lines else [])
|
||||
if button:
|
||||
text_parts.append(f"{button.label}:\n{button.url}")
|
||||
text_parts.extend(lines[1:])
|
||||
text = "\n\n".join(text_parts) + f"\n\n-- \n{footer}\n"
|
||||
|
||||
paragraphs = [
|
||||
f'<p style="margin:0 0 16px;font-size:15px;line-height:1.55;color:{INK}">{escape(line)}</p>'
|
||||
for line in lines
|
||||
]
|
||||
if button:
|
||||
url = escape(button.url, quote=True)
|
||||
action = (
|
||||
'<table role="presentation" cellpadding="0" cellspacing="0" style="margin:8px 0 24px">'
|
||||
f'<tr><td style="border-radius:8px;background:{BRAND}">'
|
||||
f'<a href="{url}" style="display:inline-block;padding:12px 22px;font-size:15px;'
|
||||
f'font-weight:600;color:{INK};text-decoration:none;border-radius:8px">{escape(button.label)}</a>'
|
||||
"</td></tr></table>"
|
||||
f'<p style="margin:0 0 16px;font-size:12px;line-height:1.5;color:{MUTED}">'
|
||||
f'If the button doesn\'t work, paste this address into your browser:<br>'
|
||||
f'<a href="{url}" style="color:{MUTED};word-break:break-all">{escape(button.url)}</a></p>'
|
||||
)
|
||||
paragraphs.insert(1 if paragraphs else 0, action)
|
||||
|
||||
html = (
|
||||
"<!doctype html>"
|
||||
f'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">'
|
||||
f"<title>{escape(subject)}</title></head>"
|
||||
f'<body style="margin:0;padding:0;background:{PAGE}">'
|
||||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{PAGE}">'
|
||||
'<tr><td align="center" style="padding:32px 16px">'
|
||||
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
|
||||
'style="max-width:520px;background:#FFFFFF;border-radius:12px;'
|
||||
f'border:1px solid {RULE};font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">'
|
||||
f'<tr><td style="padding:20px 28px;border-bottom:1px solid {RULE}">'
|
||||
f'<span style="display:inline-block;width:10px;height:10px;border-radius:5px;background:{BRAND};'
|
||||
'margin-right:8px;vertical-align:middle"></span>'
|
||||
f'<span style="font-size:15px;font-weight:700;color:{INK};vertical-align:middle">{escape(site)}</span>'
|
||||
"</td></tr>"
|
||||
f'<tr><td style="padding:28px">'
|
||||
f'<h1 style="margin:0 0 16px;font-size:20px;line-height:1.3;font-weight:700;color:{INK}">'
|
||||
f"{escape(heading)}</h1>"
|
||||
f'{"".join(paragraphs)}</td></tr>'
|
||||
f'<tr><td style="padding:16px 28px;border-top:1px solid {RULE};font-size:12px;line-height:1.5;'
|
||||
f'color:{MUTED}">{escape(footer)}</td></tr>'
|
||||
"</table></td></tr></table></body></html>"
|
||||
)
|
||||
return Email(subject=subject, text=text, html=html)
|
||||
|
||||
|
||||
def password_reset(site: str, to: str, link: str) -> Email:
|
||||
return _render(
|
||||
site,
|
||||
f"Reset your {site} password",
|
||||
"Reset your password",
|
||||
[
|
||||
f"Someone asked to reset the password for {to} on {site}. If it was you, "
|
||||
"choose a new one within the hour.",
|
||||
"Setting it signs you out everywhere else. If it wasn't you, ignore this "
|
||||
"email and your password stays as it is.",
|
||||
],
|
||||
_Button("Choose a new password", link),
|
||||
f"Sent by {site} because a password reset was asked for on this address.",
|
||||
)
|
||||
|
||||
|
||||
def test_message(site: str, public_url: str) -> Email:
|
||||
return _render(
|
||||
site,
|
||||
f"{site} test email",
|
||||
"Email works",
|
||||
[
|
||||
f"This is a test from {site} at {public_url}.",
|
||||
"Password reset and invite emails will arrive looking like this one.",
|
||||
],
|
||||
None,
|
||||
f"Sent by {site} because an admin pressed Send a test email.",
|
||||
)
|
||||
|
||||
|
||||
def invite(site: str, inviter: str, link: str, days: int) -> Email:
|
||||
when = "within a day" if days == 1 else f"within {days} days"
|
||||
return _render(
|
||||
site,
|
||||
f"{inviter} invited you to {site}",
|
||||
f"You're invited to {site}",
|
||||
[
|
||||
f"{inviter} invited you to make an account on {site}, a place to keep notes. "
|
||||
f"The link works once, and only {when}.",
|
||||
"If you weren't expecting this, you can ignore it. Nothing happens unless "
|
||||
"the link is used.",
|
||||
],
|
||||
_Button("Make your account", link),
|
||||
f"Sent by {site} on behalf of {inviter}.",
|
||||
)
|
||||
+12
-7
@@ -1,6 +1,7 @@
|
||||
"""Sending email over the SMTP server set in Settings → Email (#5266).
|
||||
|
||||
Used for password reset links, and for the admin's test message. Email is an optional
|
||||
Used for password reset links, invites, and the admin's test message. What each one
|
||||
says is in `mail_templates`; this module only delivers. Email is an optional
|
||||
feature (rule 164): with no server set, `mail_settings` answers None and everything
|
||||
that would send says it is not available. A send that fails is logged and never
|
||||
takes anything else down with it.
|
||||
@@ -18,6 +19,7 @@ from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from .common import detach
|
||||
from .mail_templates import Email
|
||||
from .settings import get_setting, mail_configured
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -53,12 +55,15 @@ async def mail_settings(db) -> MailSettings | None:
|
||||
)
|
||||
|
||||
|
||||
def build_message(cfg: MailSettings, to: str, subject: str, text: str) -> EmailMessage:
|
||||
def build_message(cfg: MailSettings, to: str, email: Email) -> EmailMessage:
|
||||
"""multipart/alternative: the text part first, the HTML part last, so a client
|
||||
picks the HTML when it can and the text when it can't."""
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg.sender
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(text)
|
||||
msg["Subject"] = email.subject
|
||||
msg.set_content(email.text)
|
||||
msg.add_alternative(email.html, subtype="html")
|
||||
return msg
|
||||
|
||||
|
||||
@@ -77,9 +82,9 @@ def _deliver(cfg: MailSettings, msg: EmailMessage) -> None:
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
async def send(cfg: MailSettings, to: str, subject: str, text: str) -> None:
|
||||
"""Send one plain-text email. Raises when the server refuses or can't be reached."""
|
||||
await asyncio.to_thread(_deliver, cfg, build_message(cfg, to, subject, text))
|
||||
async def send(cfg: MailSettings, to: str, email: Email) -> None:
|
||||
"""Send one email. Raises when the server refuses or can't be reached."""
|
||||
await asyncio.to_thread(_deliver, cfg, build_message(cfg, to, email))
|
||||
|
||||
|
||||
# Sends started by `send_later`, held so they aren't collected mid-flight.
|
||||
|
||||
@@ -22,6 +22,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import mail_templates
|
||||
from .db import session_scope
|
||||
from .mailer import mail_settings, send
|
||||
from .models.password_reset import PasswordReset
|
||||
@@ -94,15 +95,7 @@ async def mail_reset_link(email: str) -> None:
|
||||
site = await get_setting(db, "site_name")
|
||||
to = user.email
|
||||
link = f"{cfg.public_url}/reset-password?token={token}"
|
||||
await send(
|
||||
cfg,
|
||||
to,
|
||||
f"Reset your {site} password",
|
||||
f"Someone asked to reset the password for {to} on {site}.\n\n"
|
||||
f"If it was you, open this link within the hour to choose a new one:\n\n{link}\n\n"
|
||||
"Setting it signs you out everywhere else. If it wasn't you, ignore this "
|
||||
"email and your password stays as it is.\n",
|
||||
)
|
||||
await send(cfg, to, mail_templates.password_reset(site, to, link))
|
||||
logger.info("password reset email sent to=%s", to)
|
||||
except Exception:
|
||||
logger.exception("password reset email failed for=%s", email)
|
||||
|
||||
@@ -6,6 +6,7 @@ from quart import Blueprint, current_app, g, jsonify, request
|
||||
|
||||
from .auth import require_admin
|
||||
from .db import session_scope
|
||||
from . import mail_templates
|
||||
from .mailer import mail_settings, send
|
||||
from .responses import json_error
|
||||
from .models.user import User
|
||||
@@ -66,12 +67,7 @@ async def test_email():
|
||||
admin = await db.get(User, g.user_id)
|
||||
site = await get_setting(db, "site_name")
|
||||
try:
|
||||
await send(
|
||||
cfg,
|
||||
admin.email,
|
||||
f"{site} test email",
|
||||
f"This is a test from {site} at {cfg.public_url}. Password reset emails will arrive like this one.\n",
|
||||
)
|
||||
await send(cfg, admin.email, mail_templates.test_message(site, cfg.public_url))
|
||||
except Exception as e: # any SMTP, socket or TLS failure; the admin needs its text
|
||||
logger.warning("test email failed: %s", e)
|
||||
return json_error(f"Couldn't send: {e}", 502)
|
||||
|
||||
@@ -2029,7 +2029,8 @@ async def test_a_forgotten_password_is_reset_from_an_emailed_link(app_client, db
|
||||
await mailer.drain()
|
||||
|
||||
assert [m["To"] for m in outbox] == ["guest@example.test"]
|
||||
text = outbox[0].get_content()
|
||||
text = outbox[0].get_body(preferencelist=("plain",)).get_content()
|
||||
assert "reset-password?token=" in outbox[0].get_body(preferencelist=("html",)).get_content()
|
||||
link = re.search(r"https://notes\.example\.test/reset-password\?token=([\w-]+)", text)
|
||||
assert link, text
|
||||
async with session_scope() as fresh:
|
||||
@@ -2055,6 +2056,31 @@ async def test_reset_emails_stop_at_the_cap_without_saying_so(app_client, db, mo
|
||||
assert len(outbox) == 3 # reset_emails_per_account defaults to 3
|
||||
|
||||
|
||||
async def test_an_invite_is_emailed_to_its_address_with_a_working_link(app_client, db, monkeypatch):
|
||||
await _mail_off()
|
||||
outbox = _outbox(monkeypatch)
|
||||
await _admin_with_invite(app_client)
|
||||
|
||||
# Refused, and nothing made, while email is off or there is no one to send to.
|
||||
off = await app_client.post("/api/invites", json={"email": "friend@example.test", "send_email": True})
|
||||
assert off.status_code == 400
|
||||
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
|
||||
nobody = await app_client.post("/api/invites", json={"send_email": True})
|
||||
assert nobody.status_code == 400
|
||||
|
||||
made = await app_client.post("/api/invites", json={"email": "friend@example.test", "days": 7, "send_email": True})
|
||||
assert made.status_code == 201
|
||||
body = await made.get_json()
|
||||
assert body["emailed"] is True
|
||||
assert [m["To"] for m in outbox] == ["friend@example.test"]
|
||||
text = outbox[0].get_body(preferencelist=("plain",)).get_content()
|
||||
assert f"https://notes.example.test/register?invite={body['token']}" in text
|
||||
assert "within 7 days" in text
|
||||
|
||||
registered = await _register("friend@example.test", body["token"])
|
||||
assert registered.status_code == 201
|
||||
|
||||
|
||||
async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client, db, monkeypatch):
|
||||
await _mail_off()
|
||||
outbox = _outbox(monkeypatch)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from email import message_from_bytes
|
||||
from email.policy import default
|
||||
|
||||
from inkwell import mail_templates
|
||||
from inkwell.mailer import MailSettings, build_message
|
||||
|
||||
LINK = "https://notes.example.test/reset-password?token=abc-123"
|
||||
|
||||
|
||||
def test_every_email_carries_its_link_in_both_parts():
|
||||
for email in (
|
||||
mail_templates.password_reset("Inkwell", "a@example.test", LINK),
|
||||
mail_templates.invite("Inkwell", "Sam", LINK, 7),
|
||||
):
|
||||
assert LINK in email.text
|
||||
assert f'href="{LINK}"' in email.html
|
||||
|
||||
|
||||
def test_text_from_settings_and_people_is_escaped_in_the_html():
|
||||
email = mail_templates.invite('<b>Ink</b> & "co"', "<script>x</script>", LINK, 1)
|
||||
assert "<script>" not in email.html
|
||||
assert "<b>Ink</b> &" in email.html
|
||||
# The text part is plain text, so it is left as written.
|
||||
assert "<script>x</script>" in email.text
|
||||
assert "within a day" in email.text
|
||||
|
||||
|
||||
def test_the_html_fetches_nothing():
|
||||
"""No remote images or fonts: the app sends with no outbound network, and a remote
|
||||
image in an email is a tracking pixel."""
|
||||
html = mail_templates.password_reset("Inkwell", "a@example.test", LINK).html
|
||||
assert "<img" not in html
|
||||
assert "src=" not in html
|
||||
assert "@import" not in html
|
||||
|
||||
|
||||
def test_the_message_is_text_first_then_html():
|
||||
cfg = MailSettings("smtp.example.test", 587, "starttls", "", "", "inkwell@example.test", "https://x")
|
||||
msg = build_message(cfg, "a@example.test", mail_templates.test_message("Inkwell", "https://x"))
|
||||
parsed = message_from_bytes(msg.as_bytes(), policy=default)
|
||||
assert parsed.get_content_type() == "multipart/alternative"
|
||||
assert [p.get_content_type() for p in parsed.iter_parts()] == ["text/plain", "text/html"]
|
||||
assert parsed["Subject"] == "Inkwell test email"
|
||||
Reference in New Issue
Block a user