## Symptom
Grafana's \`app-crash-log\` alert fires on every deploy. The Loki query
that backs it (\`ERR_|FATAL|uncaughtException|…\`) matches:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/apps/journal/app/lib/logger.server' imported from
'/app/apps/journal/app/lib/email.server.ts'
A real bug, not a false positive: \`email.server.ts\` line 2 was
\`import { logger } from \"./logger.server\"\` — no extension.
## Why typecheck didn't catch it
\`tsconfig.base.json\` sets \`moduleResolution: \"bundler\"\`. The bundler
resolver accepts extensionless relative imports because Vite / esbuild
/ webpack resolve them at build time. TypeScript was happy.
Production runs \`node --experimental-strip-types server.ts\`, which
uses Node's NodeNext ESM resolver — strict about extensions. The two
resolvers disagree silently for files Node loads directly (server.ts,
\`.server.ts\` modules dynamically imported from it, and jobs).
## Fix
1. **Added \`.ts\` extensions to the 4 broken imports** I could find:
- \`apps/journal/app/lib/email.server.ts\` → \`./logger.server.ts\`
(the actual deploy-blocker)
- \`apps/planner/app/lib/brouter.ts\` → \`./route-merge.ts\`, \`./http.server.ts\`
- \`apps/planner/app/lib/use-nearby-pois.ts\` → \`./overpass.ts\`
- \`packages/map/src/MapView.tsx\` → \`./layers.ts\` (caught by the rule)
2. **Added \`eslint-plugin-import-x\` with \`import-x/extensions: always\`**,
scoped to files Node actually executes raw:
- \`apps/*/server.ts\`
- \`apps/*/app/lib/**/*.server.ts\`
- \`apps/*/app/jobs/**/*.ts\`
- \`packages/*/src/**/*.{ts,tsx}\`
Ignored: route-scoped \`*.server.ts\` files (bundled by Vite into the
React Router build, never loaded by Node), and test files (Vitest's
own resolver).
## What's still possible
- Non-\`.server.ts\` files in \`app/lib\` (e.g. \`legal.ts\`, \`actor-iri.ts\`)
could still ship extensionless imports. They're not in the lint
scope. If one breaks at runtime we can extend the glob — for now
they tend to be tiny utility modules that don't import other
relatives.
- The deeper fix would be \`moduleResolution: nodenext\` for server-side
tsconfig, or bundling the server code so Node never sees raw \`.ts\`.
Bigger surgery; the lint rule covers the failure mode for now.
Full repo: pnpm typecheck / lint / test all green after \`pnpm install\`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.6 KiB
TypeScript
110 lines
4.6 KiB
TypeScript
import { createTransport, type Transporter } from "nodemailer";
|
|
import { logger } from "./logger.server.ts";
|
|
|
|
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") {
|
|
logger.info({ to, subject }, "Email sent (dev mode — logged, not delivered)");
|
|
logger.debug({ text }, "Email text content");
|
|
return;
|
|
}
|
|
|
|
await getTransporter().sendMail({ from: FROM, to, subject, html, text });
|
|
}
|
|
|
|
// --- Templates ---
|
|
|
|
export function magicLinkTemplate(link: string, code?: string): { html: string; text: string } {
|
|
const codeSection = code
|
|
? `<p style="color: #555; line-height: 1.6;">Or enter this code on the login page:</p>
|
|
<div style="margin: 16px 0; padding: 16px; background: #f3f4f6; border-radius: 8px; text-align: center;">
|
|
<span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #111;">${code}</span>
|
|
</div>`
|
|
: "";
|
|
|
|
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>
|
|
${codeSection}
|
|
<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 codeText = code ? `\nOr enter this code: ${code}\n` : "";
|
|
|
|
const text = [
|
|
"Sign in to trails.cool",
|
|
"",
|
|
`Click here to sign in: ${link}`,
|
|
codeText,
|
|
"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, code?: string): Promise<void> {
|
|
const { html, text } = magicLinkTemplate(link, code);
|
|
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);
|
|
}
|