From d8777a7a231ec048eeea784e1747b804e284e045 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 19 Oct 2026 14:48:59 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8:=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=84?= =?UTF-8?q?=D0=B8=D0=B4=D0=B1=D0=B5=D0=BA=D0=B0=20=D1=83=20=D0=BA=D1=83?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0=20=D0=B8=20=D1=86=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=8B=20=D0=B2=20=D0=BF=D0=B8=D1=81=D1=8C?= =?UTF-8?q?=D0=BC=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было. В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал: кликабельность зависела от почтовика. Теперь ссылки оформлены . Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/curator/homework/[submissionId]/page.tsx | 2 +- src/lib/__tests__/comment-reply-email.test.ts | 16 ++++++++++++++++ src/lib/email.ts | 16 +++++++++++++++- src/lib/linkify.tsx | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/app/curator/homework/[submissionId]/page.tsx b/src/app/curator/homework/[submissionId]/page.tsx index a70b38a..9bf6031 100644 --- a/src/app/curator/homework/[submissionId]/page.tsx +++ b/src/app/curator/homework/[submissionId]/page.tsx @@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) { {new Date(fb.createdAt).toLocaleString("ru-RU")} -

{fb.text}

+

{linkify(fb.text)}

{fbFiles.length > 0 && (
{fbFiles.map((f) => ( diff --git a/src/lib/__tests__/comment-reply-email.test.ts b/src/lib/__tests__/comment-reply-email.test.ts index bf12309..11d3440 100644 --- a/src/lib/__tests__/comment-reply-email.test.ts +++ b/src/lib/__tests__/comment-reply-email.test.ts @@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => { expect(html).not.toContain("Отвечает "); expect(html).toContain("Привет!"); }); + + it("делает ссылки в тексте ответа кликабельными", async () => { + await sendCommentReplyEmail( + "student@example.com", + "Екатерина", + "6.3. Холст", + "Подарок тут: https://obsidian.second-brain.ru/secret-bonus?a=1&b=2. Пишите!", + LESSON_URL + ); + + const html = sent[0].html; + expect(html).toContain( + '. Пишите!"); + }); }); diff --git a/src/lib/email.ts b/src/lib/email.ts index 7210132..386e42a 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1,5 +1,6 @@ import { Resend } from "resend"; import { getSetting } from "./settings"; +import { URL_RE } from "./linkify"; function getResend() { return new Resend(process.env.RESEND_API_KEY); @@ -97,11 +98,24 @@ function escapeHtml(text: string) { .replace(/"/g, """); } +// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку. +// Экранируем по частям, чтобы `&` в адресе не превратился в `&` внутри href дважды. +function linkifyHtml(text: string) { + return text + .split(URL_RE) + .map((part, i) => + i % 2 === 1 + ? `${escapeHtml(part)}` + : escapeHtml(part) + ) + .join(""); +} + function quote(text: string) { return `
-

${escapeHtml(text).replace(/\n/g, "
")}

+

${linkifyHtml(text).replace(/\n/g, "
")}

`; diff --git a/src/lib/linkify.tsx b/src/lib/linkify.tsx index 1a56668..1f6a601 100644 --- a/src/lib/linkify.tsx +++ b/src/lib/linkify.tsx @@ -1,6 +1,6 @@ import { Fragment, type ReactNode } from "react"; -const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g; +export const URL_RE = /(https?:\/\/[^\s<>()]+[^\s<>().,;:!?])/g; export function linkify(text: string): ReactNode[] { if (!text) return [];