Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/api/v1/docs/[slug]/comments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CreateCommentBody, commentBodyBadRequest } from "@/lib/docs/schemas";
import { findBySlug } from "@/lib/docs/store";
import { canView } from "@/lib/docs/access";
import { resolveAccess, type DocAccess } from "@/lib/docs/grants";
import { sendCommentNotification } from "@/lib/docs/comment-notify";
import { checkLimits } from "@/lib/auth/ratelimit";
import { parseAnchor, type TextAnchor } from "@/lib/docs/anchor";
import {
Expand Down Expand Up @@ -134,6 +135,12 @@ export async function POST(req: Request, ctx: Ctx): Promise<Response> {
return apiError(422, "bad_parent", "parent_id must reference a live top-level comment on this document.");
}

// Comment notification. Best-effort, like the share notification: the comment
// is already committed, so a send failure or tripped cap never fails the
// request. Notifies the owner (top-level) or the owner + thread participants
// (replies), minus the author.
await sendCommentNotification({ req, doc, comment: result.comment });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment create omits notified

Medium Severity

The POST handler for creating comments ignores the email count returned by sendCommentNotification. As a result, the API response, CommentCreatedResponse, and OpenAPI schema are missing the notified count, which is part of the feature's documented contract.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9af3670. Configure here.

return json({ comment: commentView(result.comment, []) }, 201);
}

Expand Down
35 changes: 13 additions & 22 deletions app/api/v1/docs/[slug]/grants/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,28 +137,19 @@ export async function POST(req: Request, ctx: Ctx): Promise<Response> {

// Share notification (birthday.md "Share notifications"). Sent only for a
// freshly created or role-changed EMAIL grant, only when notify !== false.
// Domain grants never notify. The grant is already committed; a send failure
// or rate-limit never fails the request (the /d/:slug "was this shared with
// you?" fallback always recovers a missed/expired link).
let notified: boolean | undefined;
if (granteeType === "email") {
if (notify) {
const res = await sendShareNotification({
req,
docId: doc.id,
slug: doc.slug,
title: doc.title || doc.slug,
ownerEmail: principal.email,
granteeEmail: grantee,
});
notified = res.sent;
} else {
notified = false;
}
// Domain grants never notify. Best-effort: the grant is already committed, so a
// send failure or rate-limit never fails the request (the /d/:slug "was this
// shared with you?" fallback always recovers a missed/expired link).
if (granteeType === "email" && notify) {
await sendShareNotification({
req,
docId: doc.id,
slug: doc.slug,
title: doc.title || doc.slug,
ownerEmail: principal.email,
granteeEmail: grantee,
});
}

return json(
{ slug: doc.slug, grant: grantView(result.grant), ...(notified !== undefined ? { notified } : {}) },
201
);
return json({ slug: doc.slug, grant: grantView(result.grant) }, 201);
}
1 change: 1 addition & 0 deletions lib/auth/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type AuditEvent =
| "login_link.requested"
| "session.created"
| "share_notification.sent"
| "comment_notification.sent"
| "rate_limit.tripped";

export async function audit(
Expand Down
170 changes: 170 additions & 0 deletions lib/auth/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,173 @@ export async function sendShareEmail(opts: {
}
return data?.id ?? null;
}

// --- Comment notification (sibling of the share notification) ---
//
// Sent when someone comments on a doc. Recipients are the owner (top-level) and
// the owner + thread participants (replies), minus the comment's author. Each
// carries a single 7-day share-kind login link landing on /d/:slug (same link
// mechanics as the share email), so the recipient signs in and reads the thread.
// Two layouts, both inside the LOCKED Variant B man-page style:
// - top-level → Variant C: optional anchored-passage line, then the body quote.
// - reply → Variant D: minimal parent context, then the reply quote.
// The "why am I getting this" footer line keys off whether the recipient owns
// the doc or is a thread participant.

const COMMENT_EXPIRY_DAYS = SHARE_EXPIRY_DAYS;

// Indented quoted block (the comment/reply body) — the reference's grey-rule
// blockquote: a 2px left rule, 12px indent, LEAD type.
function quoteBlock(text: string): string {
return `<tr><td>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%"><tr><td style="border-left:2px solid #cccccc; padding:2px 0 2px 12px; ${LEAD}">${esc(text)}</td></tr></table>
</td></tr>`;
}

/**
* Subject line, mirroring `shareSubject`'s shape:
* top-level: `<author> commented on "<title>" — justhtml.sh`
* reply: `<author> replied on "<title>" — justhtml.sh`
*/
export function commentSubject(authorEmail: string, title: string, isReply: boolean): string {
const verb = isReply ? "replied on" : "commented on";
return `${authorEmail} ${verb} "${title}" — justhtml.sh`;
}

// The two flavors of the "why am I getting this" footer sentence.
function whyLine(isOwnerRecipient: boolean): string {
return isOwnerRecipient
? "You're getting this because you own this document."
: "You're getting this because you're part of this thread.";
}

type CommentEmailParts = {
authorEmail: string;
title: string;
isReply: boolean;
isOwnerRecipient: boolean;
bodySnippet: string;
anchoredQuote?: string | null; // top-level only: the document passage (anchor.exact)
parentAuthorEmail?: string | null; // reply only
parentSnippet?: string | null; // reply only
link: string;
docUrl: string;
};

function commentHtmlBody(opts: CommentEmailParts): string {
const verb = opts.isReply ? "replied on" : "commented on";
const lead = `<tr><td style="${LEAD}">${esc(opts.authorEmail)} ${verb} <strong>"${esc(opts.title)}"</strong>.</td></tr>`;

// Context row above the body quote: the anchored passage (top-level, Variant C)
// or the parent snippet (reply, Variant D). Both render as a muted caveat row
// followed by a tight 10px gap, matching the reference.
let context = "";
if (opts.isReply) {
if (opts.parentSnippet) {
const who = opts.parentAuthorEmail ? esc(opts.parentAuthorEmail) : "an earlier comment";
context = `${gap(16)}<tr><td style="${CAVEAT}">In reply to ${who}: &ldquo;${esc(opts.parentSnippet)}&rdquo;</td></tr>
${gap(10)}`;
} else {
context = gap(16);
}
} else if (opts.anchoredQuote) {
context = `${gap(16)}<tr><td style="${CAVEAT}">On: &ldquo;${esc(opts.anchoredQuote)}&rdquo;</td></tr>
${gap(10)}`;
} else {
context = gap(16);
}

// The owner signs in normally (they have an account); a non-owner participant
// may be a share grantee with no account, so they get the "no account needed"
// + "was this shared with you?" recovery copy (the share email's).
const footer = opts.isOwnerRecipient
? `<tr><td style="${CAVEAT}">Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(true)} If the link expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and sign in.</td></tr>`
: `<tr><td style="${CAVEAT}">Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days. ${whyLine(false)} If it expires, <a href="${esc(opts.docUrl)}" style="color:#666666;">open the document</a> and choose "was this shared with you? sign in".</td></tr>`;

const rows = `${lead}
${context}${quoteBlock(opts.bodySnippet)}
${gap(16)}<tr><td style="${LEAD}"><a href="${esc(opts.link)}" style="${LINK}">Open the document &rarr;</a></td></tr>
${gap(16)}${footer}`;
return shell(opts.isReply ? "new reply on justhtml.sh" : "new comment on justhtml.sh", rows);
}

function commentTextBody(opts: CommentEmailParts): string {
const verb = opts.isReply ? "replied on" : "commented on";
const lines: string[] = [`${opts.authorEmail} ${verb} "${opts.title}" on justhtml.sh.`, ""];

if (opts.isReply) {
if (opts.parentSnippet) {
const who = opts.parentAuthorEmail || "an earlier comment";
lines.push(`In reply to ${who}: "${opts.parentSnippet}"`, "");
}
} else if (opts.anchoredQuote) {
lines.push(`On: "${opts.anchoredQuote}"`, "");
}

lines.push(` ${opts.bodySnippet}`, "", "Open the document:", ` ${opts.link}`, "");
if (opts.isOwnerRecipient) {
lines.push(
`This sign-in link is good for ${COMMENT_EXPIRY_DAYS} days.`,
whyLine(true),
"If it expires, sign in at justhtml.sh and open the document:",
` ${opts.docUrl}`
);
} else {
lines.push(
`Signs you in on this device, no account needed. Good for ${COMMENT_EXPIRY_DAYS} days.`,
whyLine(false),
'If it expires, open the document directly and choose "was this shared with you? sign in":',
` ${opts.docUrl}`
);
}
return lines.join("\n");
}

/**
* Send a comment-notification email. Returns the Resend message id on success,
* or throws on send failure so the caller can roll back the just-minted token
* row (the comment is already committed; a missed email is recoverable via the
* /d/:slug sign-in fallback).
*/
export async function sendCommentEmail(opts: {
to: string;
authorEmail: string;
title: string;
isReply: boolean;
isOwnerRecipient: boolean;
bodySnippet: string;
anchoredQuote?: string | null; // top-level, optional (anchor.exact)
parentAuthorEmail?: string | null; // reply
parentSnippet?: string | null; // reply
link: string;
docUrl: string; // bare https://justhtml.sh/d/:slug — the stale-link recovery target
idempotencyKey: string;
}): Promise<string | null> {
const parts: CommentEmailParts = {
authorEmail: opts.authorEmail,
title: opts.title,
isReply: opts.isReply,
isOwnerRecipient: opts.isOwnerRecipient,
bodySnippet: opts.bodySnippet,
anchoredQuote: opts.anchoredQuote,
parentAuthorEmail: opts.parentAuthorEmail,
parentSnippet: opts.parentSnippet,
link: opts.link,
docUrl: opts.docUrl,
};
const { data, error } = await resend().emails.send(
{
from: RESEND_FROM,
to: opts.to,
subject: commentSubject(opts.authorEmail, opts.title, opts.isReply),
html: commentHtmlBody(parts),
text: commentTextBody(parts),
tags: [{ name: "flow", value: "comment_notification" }],
},
{ idempotencyKey: opts.idempotencyKey }
);
if (error) {
throw new Error(`resend send failed: ${error.message ?? String(error)}`);
}
return data?.id ?? null;
}
Loading
Loading