AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6

Merged
bvandeusen merged 214 commits from dev into main 2026-10-21 21:40:00 -04:00
9 changed files with 291 additions and 33 deletions
Showing only changes of commit c0de50d85d - Show all commits
+2 -1
View File
@@ -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
+27 -7
View File
@@ -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>
+26 -2
View File
@@ -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("")
+150
View File
@@ -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
View File
@@ -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.
+2 -9
View File
@@ -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)
+2 -6
View File
@@ -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)
+27 -1
View File
@@ -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)
+43
View File
@@ -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 "&lt;b&gt;Ink&lt;/b&gt; &amp;" 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"