Add transactional emails (SMTP) and planner features (no-go areas, notes, crash recovery)
Transactional emails: - Add nodemailer SMTP email module with dev-mode console logging - Magic link template and welcome template with HTML + plain text - Wire sendMagicLink into login flow, sendWelcome into registration - Update privacy page and deploy docs for SMTP configuration Planner features: - No-go areas: draw polygons on map (leaflet-geoman), synced via Yjs, passed to BRouter as nogos parameter, route recomputes on change - Session notes: collaborative Y.Text textarea in sidebar tab - Crash recovery: periodic localStorage save of Yjs state, restore on reconnect - Rate limit session creation (10/IP/hour) in /new route Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05b5f6febf
commit
0a8dd0b766
27 changed files with 1030 additions and 69 deletions
65
apps/journal/app/lib/email.server.test.ts
Normal file
65
apps/journal/app/lib/email.server.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock nodemailer before importing
|
||||
vi.mock("nodemailer", () => ({
|
||||
createTransport: vi.fn().mockReturnValue({
|
||||
sendMail: vi.fn().mockResolvedValue({ messageId: "test-id" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("email.server", () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
delete process.env.SMTP_URL;
|
||||
});
|
||||
|
||||
it("logs to console in dev mode", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
const { sendEmail } = await import("./email.server");
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await sendEmail("test@example.com", "Test Subject", "<p>Hello</p>", "Hello");
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("test@example.com"),
|
||||
);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not call SMTP in dev mode", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
const nodemailer = await import("nodemailer");
|
||||
const { sendEmail } = await import("./email.server");
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await sendEmail("test@example.com", "Test", "<p>Hi</p>", "Hi");
|
||||
|
||||
expect(nodemailer.createTransport).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("magicLinkTemplate includes link and expiry note", async () => {
|
||||
const { magicLinkTemplate } = await import("./email.server");
|
||||
const { html, text } = magicLinkTemplate("https://trails.cool/auth/verify?token=abc");
|
||||
|
||||
expect(html).toContain("https://trails.cool/auth/verify?token=abc");
|
||||
expect(html).toContain("15 minutes");
|
||||
expect(text).toContain("https://trails.cool/auth/verify?token=abc");
|
||||
expect(text).toContain("15 minutes");
|
||||
});
|
||||
|
||||
it("welcomeTemplate includes username", async () => {
|
||||
const { welcomeTemplate } = await import("./email.server");
|
||||
const { html, text } = welcomeTemplate("Alice");
|
||||
|
||||
expect(html).toContain("Alice");
|
||||
expect(text).toContain("Alice");
|
||||
expect(html).toContain("trails.cool");
|
||||
});
|
||||
});
|
||||
99
apps/journal/app/lib/email.server.ts
Normal file
99
apps/journal/app/lib/email.server.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { createTransport, type Transporter } from "nodemailer";
|
||||
|
||||
const FROM = process.env.SMTP_FROM ?? "trails.cool <noreply@trails.cool>";
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
function getTransporter(): Transporter {
|
||||
if (!transporter) {
|
||||
const url = process.env.SMTP_URL;
|
||||
if (!url) throw new Error("SMTP_URL is not set");
|
||||
transporter = createTransport(url);
|
||||
}
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log(`[Email] To: ${to} | Subject: ${subject}`);
|
||||
console.log(`[Email] Text:\n${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await getTransporter().sendMail({ from: FROM, to, subject, html, text });
|
||||
}
|
||||
|
||||
// --- Templates ---
|
||||
|
||||
export function magicLinkTemplate(link: string): { html: string; text: string } {
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Sign in to trails.cool</h1>
|
||||
<p style="color: #555; line-height: 1.6;">Click the button below to sign in to your account. This link expires in 15 minutes.</p>
|
||||
<a href="${link}" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">Sign In</a>
|
||||
<p style="color: #888; font-size: 14px;">If the button doesn't work, copy and paste this link:<br/><a href="${link}" style="color: #2563eb;">${link}</a></p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 12px;">If you didn't request this link, you can safely ignore this email.</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const text = [
|
||||
"Sign in to trails.cool",
|
||||
"",
|
||||
`Click here to sign in: ${link}`,
|
||||
"",
|
||||
"This link expires in 15 minutes.",
|
||||
"",
|
||||
"If you didn't request this link, you can safely ignore this email.",
|
||||
].join("\n");
|
||||
|
||||
return { html, text };
|
||||
}
|
||||
|
||||
export function welcomeTemplate(username: string): { html: string; text: string } {
|
||||
const html = `
|
||||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
|
||||
<h1 style="font-size: 24px; font-weight: 600; color: #111;">Welcome to trails.cool, ${username}!</h1>
|
||||
<p style="color: #555; line-height: 1.6;">Your account is ready. Here's what you can do:</p>
|
||||
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Save and manage your routes</li>
|
||||
<li>Track outdoor activities</li>
|
||||
<li>Plan routes collaboratively in the Planner</li>
|
||||
<li>Export routes as GPX for any GPS device</li>
|
||||
</ul>
|
||||
<a href="https://trails.cool/routes" style="display: inline-block; margin: 24px 0; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 8px; font-weight: 500;">View Your Routes</a>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
|
||||
<p style="color: #aaa; font-size: 12px;">trails.cool — Your outdoor activity journal</p>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
const text = [
|
||||
`Welcome to trails.cool, ${username}!`,
|
||||
"",
|
||||
"Your account is ready. Here's what you can do:",
|
||||
"- Save and manage your routes",
|
||||
"- Track outdoor activities",
|
||||
"- Plan routes collaboratively in the Planner",
|
||||
"- Export routes as GPX for any GPS device",
|
||||
"",
|
||||
"View your routes: https://trails.cool/routes",
|
||||
].join("\n");
|
||||
|
||||
return { html, text };
|
||||
}
|
||||
|
||||
// --- Convenience wrappers ---
|
||||
|
||||
export async function sendMagicLink(email: string, link: string): Promise<void> {
|
||||
const { html, text } = magicLinkTemplate(link);
|
||||
await sendEmail(email, "Sign in to trails.cool", html, text);
|
||||
}
|
||||
|
||||
export async function sendWelcome(email: string, username: string): Promise<void> {
|
||||
const { html, text } = welcomeTemplate(username);
|
||||
await sendEmail(email, "Welcome to trails.cool!", html, text);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.login";
|
||||
import { startAuthentication, finishAuthentication, createMagicToken, createSession } from "~/lib/auth.server";
|
||||
import { sendMagicLink } from "~/lib/email.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
|
|
@ -22,12 +23,13 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
const token = await createMagicToken(email);
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
const link = `${origin}/auth/verify?token=${token}`;
|
||||
console.log(`[Magic Link] ${email}: ${link}`);
|
||||
|
||||
// In dev, return the link directly so the client can auto-redirect
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.log(`[Magic Link] ${email}: ${link}`);
|
||||
return data({ step: "magic-link-sent", devLink: link });
|
||||
}
|
||||
|
||||
await sendMagicLink(email, link);
|
||||
return data({ step: "magic-link-sent" });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { data } from "react-router";
|
||||
import type { Route } from "./+types/api.auth.register";
|
||||
import { startRegistration, finishRegistration, createSession, addPasskeyStart, addPasskeyFinish } from "~/lib/auth.server";
|
||||
import { sendWelcome } from "~/lib/email.server";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const body = await request.json();
|
||||
|
|
@ -15,6 +16,10 @@ export async function action({ request }: Route.ActionArgs) {
|
|||
if (step === "finish") {
|
||||
const newUserId = await finishRegistration(userId, email, username, response, challenge);
|
||||
const cookie = await createSession(newUserId, request);
|
||||
// Send welcome email (fire-and-forget — don't block registration on email)
|
||||
sendWelcome(email, username).catch((err) =>
|
||||
console.error("[Email] Failed to send welcome email:", err),
|
||||
);
|
||||
return data({ step: "done" }, { headers: { "Set-Cookie": cookie } });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,12 +70,30 @@ export default function PrivacyPage() {
|
|||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Email</h2>
|
||||
<p className="mt-2 text-gray-600">
|
||||
The Journal sends transactional emails for magic link login and welcome messages
|
||||
via SMTP. On the official instance, emails are sent through our own mail server.
|
||||
</p>
|
||||
<h3 className="mt-4 font-medium text-gray-800">What is sent via email:</h3>
|
||||
<ul className="mt-2 list-disc pl-6 text-gray-600 space-y-1">
|
||||
<li><strong>Magic link</strong>: a one-time login link sent to your email address</li>
|
||||
<li><strong>Welcome email</strong>: a greeting after registration</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-gray-600">
|
||||
Self-hosted instances can configure their own SMTP server. No email content
|
||||
is stored beyond what your mail server retains.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Third Parties</h2>
|
||||
<ul className="mt-3 list-disc pl-6 text-gray-600 space-y-1">
|
||||
<li><strong>Sentry</strong> (Functional Software Inc.) — error tracking, as described above</li>
|
||||
<li><strong>OpenStreetMap</strong> — map tiles are loaded from OSM tile servers. OSM's <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" className="text-blue-600 hover:underline">privacy policy</a> applies to tile requests.</li>
|
||||
<li><strong>BRouter</strong> — routing requests are processed by our self-hosted BRouter instance. No data is sent to third parties for routing.</li>
|
||||
<li><strong>SMTP provider</strong> — transactional emails (magic link, welcome) are delivered via SMTP. Self-hosters configure their own provider.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"jose": "^6.2.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:"
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
"@react-router/dev": "catalog:",
|
||||
"@simplewebauthn/types": "^12.0.0",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue