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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user