Кликабельные ссылки: история фидбека у куратора и цитаты в письмах

Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было.
В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал:
кликабельность зависела от почтовика. Теперь ссылки оформлены <a>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:48:59 +05:00
co-authored by Claude Opus 5.5
parent 4792df274e
commit d8777a7a23
4 changed files with 33 additions and 3 deletions
@@ -229,7 +229,7 @@ export default async function SubmissionPage({ params }: Props) {
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
<p className="text-sm whitespace-pre-wrap mb-2">{linkify(fb.text)}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
@@ -83,4 +83,20 @@ describe("sendCommentReplyEmail", () => {
expect(html).not.toContain("Отвечает <strong>");
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(
'<a href="https://obsidian.second-brain.ru/secret-bonus?a=1&amp;b=2" target="_blank"'
);
expect(html).toContain("secret-bonus?a=1&amp;b=2</a>. Пишите!");
});
});
+15 -1
View File
@@ -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, "&quot;");
}
// Ссылки в цитате — кликабельные: не все почтовики сами превращают голый адрес в ссылку.
// Экранируем по частям, чтобы `&` в адресе не превратился в `&amp;` внутри href дважды.
function linkifyHtml(text: string) {
return text
.split(URL_RE)
.map((part, i) =>
i % 2 === 1
? `<a href="${escapeHtml(part)}" target="_blank" style="color:#323232;text-decoration:underline;word-break:break-all;">${escapeHtml(part)}</a>`
: escapeHtml(part)
)
.join("");
}
function quote(text: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
<tr>
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${escapeHtml(text).replace(/\n/g, "<br/>")}</p>
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${linkifyHtml(text).replace(/\n/g, "<br/>")}</p>
</td>
</tr>
</table>`;
+1 -1
View File
@@ -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 [];