IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime14/69-dars19 daqiqa
Mundarija (26)

Email shablonlari serverda: Handlebars, MJML va React Email

Qisqacha: Email xati — har foydalanuvchi uchun ma'lumot bilan to'ldiriladigan HTML shablon. Serverda uni Handlebars ({{ism}} — avtomatik ekranlanadi, strict: true — yo'q o'zgaruvchi xato beradi), MJML (responsive jadval HTML yasaydi) yoki React Email (komponentlar, render() va plainText) bilan yig'ish mumkin. Har xatda HTML va matn versiyasi bo'lsin, foydalanuvchi ma'lumoti doim ekranlansin, havola esa faqat server yasagan https:// manzil bo'lsin — ekranlash javascript: sxemasini to'xtatmaydi.

Bu darsda

  • Nega email shablonini serverda render qilish kerakligini va qaysi vositani qachon tanlashni bilasiz.
  • Handlebars bilan layout, partial va helper yozasiz, {{ }} va {{{ }}} farqini ko'rasiz.
  • Kanon o'lchovlari bilan juice, MJML 5 va React Email 6 ni solishtirasiz.
  • HTML ekranlash va xavfsiz havola funksiyalarini o'zingiz yozasiz.
  • Pochta modulining shablon qismini tugatib, modulni commit qilasiz.

Oldin bilishingiz kerak: HTML email'larni stillash (juice, MJML, React Email — dizayn tomoni), Template engine'lar va server-side HTML, XSS va DOM xavfsizligi, Email yuborish: SMTP va Nodemailer.

Oldingi darsda Pochta.yubor() tayyor { mavzu, html, matn } ni yubordi. Bu matnlar qayerdan keladi? Bugun ularni shablondan yig'amiz.

1. Nega bu kerak?

HTML email'larni stillash darsida siz bitta xatni qo'lda yozdingiz: 600 pikselli jadval, inline stil, Outlook uchun shartli izoh. Endi o'sha xat har kuni 500 foydalanuvchiga ketadi va har birida boshqa ism, boshqa vazifalar, boshqa havola.

Satrlarni + bilan yopishtirish birinchi kunda ishlaydi. Keyin uch muammo chiqadi:

  1. Xavfsizlik. Foydalanuvchi ismini <a href="https://yomon.uz">Hisobingiz bloklandi</a> deb yozdi. Ekranlanmagan ism xatga havola bo'lib tushadi — va xat sizning domeningizdan ketadi. Bu HTML injection, fishing uchun ideal.
  2. Ikki versiya. Har xatga matn versiyasi kerak — uni HTML'dan qo'lda takrorlash xato manbai.
  3. Dizayn va kod aralashadi. Matnni o'zgartirish uchun JavaScript satrlarini titkilash kerak.

Shablon (template) — bo'sh joylari belgilangan tayyor matn. O'xshatish: bankdagi blanka. Blanka bir marta chop etiladi, har mijoz faqat ism va summa kataklarini to'ldiradi. Katakka nima yozilsa ham, blankaning qolgan qismi buzilmaydi.

2. Handlebars: serverdagi klassik shablon

2.1 Birinchi shablon

Handlebars — {{ }} belgili shablon tili (Template engine'lar darsidagi g'oya). Shablon bir marta kompilyatsiya qilinadi — funksiyaga aylanadi. Keyin u har ma'lumot bilan chaqiriladi. Kanon handlebars 4.7.10 ni ishlatdi:

bash
npm i handlebars@4.7.10
js
import Handlebars from "handlebars";

const t = Handlebars.compile("<p>Salom, {{ism}}!</p>");
console.log(t({ ism: "Aziz" }));
console.log(t({ ism: '<b>Aziz</b> & "do\'st" =' }));

const uch = Handlebars.compile("<p>Salom, {{{ism}}}!</p>");
console.log(uch({ ism: "<b>Aziz</b>" }));

Konsolda (biz ishga tushirdik: Node 24.21.0, handlebars 4.7.10):

text
<p>Salom, Aziz!</p>
<p>Salom, &lt;b&gt;Aziz&lt;/b&gt; &amp; &quot;do&#x27;st&quot; &#x3D;!</p>
<p>Salom, <b>Aziz</b>!</p>

Qoida bitta:

  • {{ism}} — ekranlanadi: < → &lt;, & → &amp;, qo'shtirnoq va apostrof ham. Hatto = ham &#x3D; bo'ldi — atribut ichida qo'shtirnoqsiz qiymat buzilmasin uchun.
  • {{{ism}}} — uchta qavs — ekranlanmaydi. HTML xatga aynan tushadi.

Uchta qavsni foydalanuvchi ma'lumoti uchun ishlatmang. U faqat siz o'zingiz yig'gan va ishongan HTML uchun (masalan, layout ichiga tananing tayyor HTML'i).

2.2 strict — yo'q o'zgaruvchi xato bersin

js
const qattiq = Handlebars.compile("Salom, {{ism}}! {{havola}}", {
  strict: true,
});
try {
  qattiq({ ism: "Aziz" });
} catch (e) {
  console.log(e.message.split("\n")[0]);
}
const yumshoq = Handlebars.compile("Salom, {{ism}}! [{{havola}}]");
console.log(yumshoq({ ism: "Aziz" }));

Konsolda (biz ishga tushirdik):

text
"havola" not defined in [object Object] - 1:18
Salom, Aziz! []

Xato: "havola aniqlanmagan, 1-qator, 18-ustun". Sukutda esa Handlebars yo'q o'zgaruvchini jim bo'sh satrga aylantiradi: foydalanuvchi "Tasdiqlash" tugmasi bor, lekin havolasi bo'sh xat oladi. Bunday xatoni test emas, foydalanuvchi topadi. strict: true bilan esa birinchi testda yiqiladi.

2.3 Partial, helper va each

Katta xatlar bo'laklardan yig'iladi:

  • Partial — qayta ishlatiladigan bo'lak (tugma, futer): {{> tugma url=havola matn="Ochish"}}.
  • Helper — shablon ichidan chaqiriladigan JS funksiya: {{son vazifalar}}.
  • {{#each}} — ro'yxat bo'ylab takrorlash.
js
Handlebars.registerPartial("tugma", '<a href="{{url}}">{{matn}}</a>');
Handlebars.registerHelper("son", (arr) => arr.length);
const r = Handlebars.compile(
  "Bugun {{son vazifalar}} ta:" +
    "{{#each vazifalar}} {{this}};{{/each}} " +
    '{{> tugma url=havola matn="Ochish"}}',
);
console.log(r({
  vazifalar: ["Non olish", "Hisobot"],
  havola: "https://v.example/t/1",
}));

Konsolda (biz ishga tushirdik):

text
Bugun 2 ta: Non olish; Hisobot; <a href="https://v.example/t/1">Ochish</a>

Mantiqni shablonga ko'p kiritmang. Hisob-kitob (sana formati, summa) — JavaScript'da, shablonga tayyor satr beriladi. Handlebars ataylab "mantiqsiz" (logic-less) qilib yaratilgan.

Tekshirib ko'ring: Admin panelda foydalanuvchilarga "o'z shablonini yozish" imkoniyatini bermoqchisiz: matn Handlebars.compile(foydalanuvchiMatni) ga beriladi. Bu xavfsizmi?

Javob

Yo'q. Shablon — kod. Begona shablon helper'larni chaqiradi va kontekstdagi har maydonni o'qiy oladi (masalan, xatga uzatilgan butun foydalanuvchi obyekti). Bu shablon injection (server-side template injection — begona shablonni serverda bajarish). Handlebars 4.7.10 da prototip xususiyatlari sukutda berilmaydi — biz {{constructor.name}} ni sinadik, natija bo'sh. Lekin bu yagona himoya qatlami bo'lmasin: foydalanuvchiga faqat oldindan belgilangan o'zgaruvchilar ro'yxatini bering va shablonni ishonchli rol (admin) tahrirlasin.

3. Ekranlash va havolalar: o'zingiz yozing

3.1 html() funksiyasi

Handlebars ichida nima bo'layotganini tushunish uchun ekranlashni o'zimiz yozamiz:

js
const BELGILAR = {
  "&": "&amp;", "<": "&lt;", ">": "&gt;",
  '"': "&quot;", "'": "&#x27;", "`": "&#x60;", "=": "&#x3D;",
};
const html = (s) =>
  String(s).replace(/[&<>"'`=]/g, (b) => BELGILAR[b]);

console.log(html('<img src=x onerror=alert(1)> "Aziz"'));
console.log(html("Gʻayrat & Co"));
console.log(`<a href="${html("javascript:alert(1)")}">Ochish</a>`);

Konsolda:

text
&lt;img src&#x3D;x onerror&#x3D;alert(1)&gt; &quot;Aziz&quot;
Gʻayrat &amp; Co
<a href="javascript:alert(1)">Ochish</a>

Birinchi ikki qator — kutilganidek: teglar matnga aylandi, o'zbek harflari tegilmadi.

Uchinchi qator — darsning eng muhim tuzog'i. javascript:alert(1) da ekranlanadigan belgi yo'q. Ekranlash atributdan "chiqib ketishni" to'xtatadi, lekin atribut ichidagi qiymatning ma'nosini tekshirmaydi. Kanon modul testi aynan shuni qayd etgan: href="{{havola}}" ga javascript: sxemasi o'tadi.

Pochta ilovalarining ko'pchiligi javascript: havolasini bloklaydi. Lekin https://yomon.uz/kirish kabi oddiy fishing havolasini bloklamaydi. Xulosa: havola foydalanuvchidan kelmasin — uni server o'zi yasaydi.

3.2 Xavfsiz havola

Ba'zan havola baribir tashqaridan keladi (masalan, vazifa ichidagi manzil). U holda uni ruxsat ro'yxati bilan tekshiring:

js
function xavfsizHavola(matn, ruxsat = ["vazifalar.example.uz"]) {
  let url;
  try {
    url = new URL(matn);
  } catch {
    return null;
  }
  if (url.protocol !== "https:") return null;
  if (!ruxsat.includes(url.hostname)) return null;
  return url.href;
}

console.log(xavfsizHavola("https://vazifalar.example.uz/t/abc"));
console.log(xavfsizHavola("javascript:alert(1)"));
console.log(xavfsizHavola("JavaScript:alert(1)"));
console.log(xavfsizHavola("https://vazifalar.example.uz.yomon.uz/t"));
console.log(xavfsizHavola("http://vazifalar.example.uz/t"));
console.log(xavfsizHavola("/t/abc"));

Konsolda:

text
https://vazifalar.example.uz/t/abc
null
null
null
null
null

Matnni new URL tahlil qiladi (URL va URLSearchParams) — startsWith("https") emas. Shuning uchun katta-kichik harfli JavaScript: va vazifalar.example.uz.yomon.uz kabi "o'xshash" domen ham ushlandi: hostname aniq solishtiriladi. Nisbiy yo'l (/t/abc) ham rad — emailda nisbiy havola ishlamaydi, xatda doim to'liq manzil.

4. Inline CSS: juice

Pochta ilovalari <style> blokini ko'pincha olib tashlaydi, shuning uchun stillar har tegning style atributiga ko'chiriladi (HTML email'larni stillash darsida juice'ni ko'rgansiz). Serverda bu render'dan keyingi qadam. Kanon Handlebars layout + partial + helper'ni juice 12.2.0 dan o'tkazdi:

text
Handlebars HTML 566 belgi → juice 608 belgi
tugma (juice'dan keyin): <a class="tugma" href="https://vazifalar.example.uz/t/abc123" style="background: #4f46e5; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px;">
@media qoidasi <style> da qoldi: true (inline qilib bo'lmaydi; Gmail ilovalari qo'llamasligi mumkin — sinalmagan)
render + juice: mediana 1.133 ms
  • .tugma klassining qoidasi tugmaning o'ziga ko'chdi.
  • @media (prefers-color-scheme: dark) — qorong'i rejim qoidasi — <style> da qoldi: media so'rovni inline qilib bo'lmaydi. Uni qo'llaydigan ilovalarda ishlaydi, qolganlarida yorug' versiya ko'rinadi.
  • Render + juice — taxminan bir millisekund. Har xat uchun qayta yasash qimmat emas.

5. MJML va React Email: serverda

5.1 MJML 5

MJML — responsive email uchun maxsus belgilash tili: <mj-section>, <mj-column>, <mj-button> yozasiz, u Outlook'ga mos jadval HTML yasaydi. Kanon MJML 5.4.1 bilan bitta xatni render qildi:

text
MJML: 5742 belgi, 32 ms (birinchi chaqiruv), xatolar: 0, <table> soni: 5, MSO shartli izohlar: 5
MJML keyingi chaqiruvlar: mediana 2.4 ms
noto'g'ri tuzilma (mj-text mj-column'siz): Line 1 of C:\Users\…\Temp\…\node (mj-text) — mj-text cannot be used inside mj-body, only inside: mj-attributes, mj-column, mj-hero

(Xatodagi lokal yo'lni qisqartirdik.)

O'n qatorga yaqin MJML 5.7 KB HTML'ga aylandi: beshta jadval va Outlook uchun beshta shartli izoh. Buni qo'lda yozish — soatlar. Noto'g'ri tuzilma esa aniq xabar beradi: "mj-text ni mj-body ichida to'g'ridan ishlatib bo'lmaydi, faqat mj-column ichida".

Kanon bitta muhim eslatma yozdi: MJML ichiga foydalanuvchi matnini JavaScript template literal bilan qo'ysangiz, u ekranlanmaydi. MJML — shablon tili emas, faqat tuzilma. Ma'lumotni qo'yishdan oldin html() dan o'tkazing yoki MJML'ni Handlebars bilan birga ishlating (avval Handlebars, keyin MJML).

5.2 React Email 6

React Email — xatni React komponentlari bilan yozish (<Html>, <Button>, <Preview>). Serverda render() uni HTML satrga aylantiradi. Kanon muhim o'zgarishni topdi: avvalgi @react-email/components va uning bo'lak paketlari npm'da deprecated (eskirgan) deb belgilangan. O'rniga bitta paket — react-email 6.11.3: komponentlar ham, render ham shu yerda.

bash
npm i react-email@6.11.3 react@19.3.1 react-dom@19.3.1

Kanon JSX'siz, createElement bilan yozdi (o'quvchi React'ni 17-qismdan biladi). Asosiy qism:

js
import { createElement as h } from "react";
import {
  Html, Head, Body, Container, Heading, Text, Button, Preview,
  render,
} from "react-email";

const Xat = ({ ism, havola, soni }) =>
  h(Html, { lang: "uz" }, h(Head),
    h(Preview, null, "Bugungi vazifalar"),
    h(Body, null,
      h(Container, null,
        h(Heading, { as: "h1" }, `Salom, ${ism}!`),
        h(Text, null, `Bugun ${soni} ta vazifa:`),
        h(Button, { href: havola }, "Ochish"))));

const html = await render(h(Xat, { ism, havola, soni: 2 }));
const matn = await render(h(Xat, { ism, havola, soni: 2 }), {
  plainText: true,
});

Kanonning natijasi (to'liq komponentda vazifalar ro'yxati va stillar ham bor). plainText versiya dan keyingi qatorlarga qarang — bu HTML'dan avtomatik olingan matn:

text
HTML 3078 belgi, birinchi render 474 ms; ism ekranlandi: true
plainText versiya:
  | SALOM, GʻAYRAT!
  | 
  | Bugun 2 ta vazifa:
  | 
  | Non olish — 28.10
  | 
  | Hisobot — 29.10
  | 
  | Ochish https://vazifalar.example.uz/t/abc123
React Email render: mediana 4.06 ms
  • Ism o'rniga <script>alert(1)</script> berilganda u ekranlandi — React matnni doim ekranlaydi.
  • plainText: true — matn versiyasi bepul: sarlavha katta harf bilan, tugma — "Ochish" va havola.
  • Birinchi render 474 ms (modullar yuklanishi), keyingilari ~4 ms. Server ishga tushganda bitta "qizdirish" render'i foydali.

5.3 Preheader va hajm

Preheader — pochta qutisida mavzudan keyingi kulrang qator (HTML email'larni stillash). React Email'da u <Preview>, kanon chiqishida:

text
Preview elementi: <div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">
Gmail ~102 KB dan katta HTML ni kesadi ('[Message clipped]' — Gmail xulqi, hujjat/kuzatuv bo'yicha; sinalmagan). Bu yerdagi xatlar: 0.6 KB, 0.6 KB, 5.6 KB, 4.6 KB

Yashirin div: xat ochilganda ko'rinmaydi, ro'yxatda esa ko'rinadi. data-skip-in-text — matn versiyasiga tushmasin. Hajm: to'rtta xat 0.6–5.6 KB. Gmail ~102 KB dan katta HTML'ni kesadi — kanon buni sinamagan, bu keng tarqalgan kuzatuv. Xat oxiridagi "obunani bekor qilish" havolasi aynan kesilgan qismda qolib ketishi mumkin.

5.4 Qaysi birini tanlash

Bitta xatni render qilish (mediana, qizdirilgandan keyin)
  • Handlebars + juice1,133 ms
  • MJML 52,4 ms
  • React Email 64,06 ms

Manba: IlmHamroh kanon tajribasi, 2026-10-27: handlebars 4.7.10, juice 12.2.0, mjml 5.4.1, react-email 6.11.3, Node 24.21.0, i5-12500H; birinchi chaqiruvlar: MJML 32 ms, React Email 474 ms

Vosita Kuchli tomoni Qachon
Handlebars (+ juice) oddiy, tez, shablon — alohida fayl kam xat, oddiy dizayn
MJML Outlook'ga mos jadvalni o'zi yasaydi dizayni murakkab xatlar
React Email komponentlar, TypeScript, plainText jamoa React'ni biladi, ko'p xat

Uchalasi ham bitta xat uchun millisekundlar — tezlik tanlov sababi emas. Kanon Pochta modulida Handlebars'ni tanladi: ikkita kichik xat uchun React va JSX bog'liqligi ortiqcha.

6. Matn versiyasi

HTML'dan matn olishning uch yo'li bor:

  1. Alohida shablon — kanon tanlovi: matn maydoni o'z Handlebars shabloni bilan. Eng aniq natija, lekin ikki joyni yangilash kerak.
  2. Avtomatik — React Email'ning plainText.
  3. O'zgartirgich — HTML'dan teglarni olib tashlash (2-mashqda o'zingiz yozasiz).

Matn versiyasida HTML ekranlash kerak emas: u brauzerda ko'rsatilmaydi. Kanon shuning uchun matn shablonini noEscape: true bilan kompilyatsiya qiladi. Aks holda Aziz Non &lt;olish&gt; ni ko'radi. Mavzu ham shunday — u HTML emas.

Tekshirib ko'ring: Matn versiyasida ekranlash yo'q. Hujumchi ismiga <script> yozsa, xavf bormi?

Javob

Yo'q. text/plain qismini pochta ilovasi matn sifatida ko'rsatadi — teg bajarilmaydi. Xavf faqat HTML qismida. Lekin matndagi havolaga o'xshash satr (https://yomon.uz) ilovada bosiladigan havola bo'lib ko'rinishi mumkin. Shuning uchun foydalanuvchi matnini uzunligi bilan cheklash va xatga faqat kerakli maydonlarni qo'yish baribir foydali.

7. Hujumchi nigohi

Hujum Natijasi Himoya
ismga <a href=…>Hisob bloklandi</a> sizning domeningizdan fishing xati {{ }} ekranlash; {{{ }}} — faqat ishonchli HTML
havolaga javascript: yoki begona domen bosilganda zararli sahifa havolani server yasaydi; tashqi bo'lsa xavfsizHavola
foydalanuvchi shablon yozadi shablon injection o'zgaruvchilar ro'yxati, faqat admin
MJML/template literal'ga xom matn HTML injection avval html() yoki Handlebars
mavzuga uzun matn spam filtri, kesilgan mavzu foydalanuvchi matnini qisqartirish

Email — XSS uchun g'alati joy: pochta ilovalari skriptni deyarli doim o'chiradi. Lekin email'dagi asosiy xavf skript emas, ishonch. Xat sizning domeningizdan keladi, SPF va DKIM tekshiruvidan o'tadi (Email yetkazib berish). Uning ichidagi har havola foydalanuvchi uchun "rasmiy". Shuning uchun HTML injection emailda veb-sahifadagidan ham xavfliroq.

8. Ko'p uchraydigan xatolar

Xato Belgisi To'g'risi
{{{ism}}} foydalanuvchi ma'lumoti uchun HTML injection {{ism}}
strict yo'q tugma havolasi bo'sh strict: true + test
matn versiyasi ham ekranlangan &amp; matnda noEscape: true faqat matn va mavzu uchun
havolani foydalanuvchidan olish fishing server yasaydi yoki ruxsat ro'yxati
@react-email/components o'rnatish npm deprecated ogohlantirishi react-email 6
nisbiy havola (/t/abc) xatda ishlamaydi to'liq https:// manzil
har xatda shablonni qayta compile ortiqcha ish modul yuklanganda bir marta

9. Mashqlar

Mashqlar kurs/mashqlar/29/14-shablon/ da.

1-mashq (oson): to'g'ri yoki noto'g'ri

  1. {{havola}} ekranlash javascript: havolasini to'xtatadi.
  2. strict: true bilan yo'q o'zgaruvchi bo'sh satrga aylanadi.
  3. Matn versiyasini ekranlash shart emas.
  4. MJML foydalanuvchi matnini o'zi ekranlaydi.
  5. React Email render() ga plainText: true berilsa, matn versiya qaytadi.
Yechim
  1. Noto'g'ri — javascript:alert(1) da ekranlanadigan belgi yo'q; kanon testi buni tasdiqladi.
  2. Noto'g'ri — strict bilan xato tashlanadi: "havola" not defined. Bo'sh satr — sukutdagi xulq.
  3. To'g'ri — u HTML sifatida ko'rsatilmaydi.
  4. Noto'g'ri — kanon eslatmasi: template literal bilan qo'yilgan matn ekranlanmaydi.
  5. To'g'ri — sarlavha katta harf bilan, tugma — matn va havola.

2-mashq (o'rta): HTML'dan matn versiya

matnVersiya(html) funksiyasini yozing: <a href="URL">Matn</a> → Matn (URL); </p>, </h1>, </li>, <br> → yangi qator; <li> → - ; qolgan teglar olib tashlansin; &amp;, &lt;, &gt;, &quot; ochilsin; ketma-ket uch va undan ko'p yangi qator ikkitaga qisqarsin; boshi va oxiridagi bo'shliq olinsin. Ishora: replace ning ikkinchi argumenti funksiya yoki $1, $2 bo'lishi mumkin (Almashtirish chuqur).

Yechim
js
const ENT = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"' };

function matnVersiya(html) {
  return html
    .replace(/<a [^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/g, "$2 ($1)")
    .replace(/<br\s*\/?>|<\/p>|<\/h1>|<\/li>/g, "\n")
    .replace(/<li>/g, "- ")
    .replace(/<[^>]+>/g, "")
    .replace(/&(amp|lt|gt|quot);/g, (e) => ENT[e])
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

const xat =
  "<h1>Salom, Aziz!</h1><p>Bugun 2 ta vazifa:</p>" +
  "<ul><li>Non olish</li><li>Hisobot &amp; chek</li></ul>" +
  '<p><a href="https://vazifalar.example.uz/t/1">Ochish</a></p>';
console.log(matnVersiya(xat));

Konsolda:

text
Salom, Aziz!
Bugun 2 ta vazifa:
- Non olish
- Hisobot & chek
Ochish (https://vazifalar.example.uz/t/1)

Tartib muhim. Havola teglar olib tashlanishidan oldin ochiladi — aks holda URL yo'qoladi. Entity'lar esa eng oxirida ochiladi: avval ochsak, &lt;b&gt; haqiqiy <b> ga aylanib, keyingi qadamda teg sifatida o'chib ketardi. Bu o'quv funksiyasi — murakkab HTML (jadvallar, ichma-ich teglar) uchun tayyor kutubxona yoki React Email plainText ishonchliroq.

3-mashq (qiyin): mini Handlebars

render(shablon, data) yozing: {{ nom }} — html() bilan ekranlangan qiymat, {{{nom}}} — xom qiymat, data da kalit bo'lmasa — Error('"nom" not defined') (strict). Kalit tekshiruvi Object.hasOwn bilan bo'lsin (Obyekt: kalit-qiymat juftliklari). To'rt holatni sinang: ekranlangan va xom qiymat, bo'shliqli {{ son }}, yo'q imzo, {{toString}}.

Yechim
js
const BELGILAR = {
  "&": "&amp;", "<": "&lt;", ">": "&gt;",
  '"': "&quot;", "'": "&#x27;", "`": "&#x60;", "=": "&#x3D;",
};
const html = (s) =>
  String(s).replace(/[&<>"'`=]/g, (b) => BELGILAR[b]);
const RE = /\{\{(\{?)\s*(\w+)\s*\}?\}\}/g;

function render(shablon, data) {
  return shablon.replace(RE, (_m, xom, nom) => {
    if (!Object.hasOwn(data, nom)) {
      throw new Error(`"${nom}" not defined`);
    }
    return xom ? String(data[nom]) : html(data[nom]);
  });
}

const s = "<p>Salom, {{ism}}!</p>{{{imzo}}}";
const d = { ism: "<b>Ali</b>", imzo: "<i>vazifalar</i>" };
console.log(render(s, d));
console.log(render("{{ son }} ta", { son: 3 }));
try {
  render(s, { ism: "Ali" });
} catch (e) {
  console.log(e.message);
}
try {
  render("{{toString}}", {});
} catch (e) {
  console.log(e.message);
}

Konsolda:

text
<p>Salom, &lt;b&gt;Ali&lt;/b&gt;!</p><i>vazifalar</i>
3 ta
"imzo" not defined
"toString" not defined

Oxirgi holat — nega in emas, Object.hasOwn: "toString" in {} — true, chunki u prototipdan keladi. in bilan shablon [object Object] emas, funksiya matnini xatga yozib yuborardi. Bu Handlebars prototip himoyasining kichik nusxasi. Regex {{{nom}} kabi teng bo'lmagan qavslarni ham qabul qiladi — haqiqiy shablon tili tahlilchisi (parser) buni xato deydi.

10. Loyiha qadami: Pochta moduli — shablonlar

Oldingi darsdagi pochta.ts ga shablon qismini qo'shamiz. Avval butun yo'lni ko'ramiz: bitta ma'lumot obyekti uchta shablondan o'tadi, faqat HTML yo'lida ekranlash bor:

flowchart LR
  D["ma'lumot: matn, muddat, obunaBekor"] --> M["mavzu shabloni (noEscape)"]
  D --> H["HTML shabloni (ekranlaydi)"]
  D --> T["matn shabloni (noEscape)"]
  M --> X["{ mavzu, html, matn }"]
  H --> X
  T --> X
  X --> Y["Pochta.yubor() → SMTP"]

Kanonda ikkita shablon bor: tasdiqlash (28-qismdagi email tasdiqlash uchun, jadval layout, preheader, qorong'i rejim meta teglari) va eslatma. Telefon uchun uzun qatorlarni bo'ldik; eslatma HTML'ida qator bo'linishi qo'shildi — brauzer uni bo'shliq sifatida ko'rsatadi, ma'nosi kanondagidek:

ts
import Handlebars from "handlebars";

/** Handlebars: `{{x}}` HTML-ekranlanadi, `{{{x}}}` — yo'q
 * (foydalanuvchi ma'lumoti uchun ISHLATILMAYDI).
 * `strict` — yo'q o'zgaruvchi xato. */
export function shablonKompilyatsiya(
  s: Shablon,
): (data: Record<string, unknown>) => Shablon {
  const opt = { strict: true, noEscape: false };
  // mavzu — matn, HTML emas; CRLF ni nodemailer o'zi tozalaydi
  const xom = { ...opt, noEscape: true };
  const mavzu = Handlebars.compile(s.mavzu, xom);
  const html = Handlebars.compile(s.html, opt);
  const matn = Handlebars.compile(s.matn, xom);
  return (d) => ({ mavzu: mavzu(d), html: html(d), matn: matn(d) });
}

export const SHABLONLAR = {
  tasdiqlash: {
    mavzu: "Emailingizni tasdiqlang — vazifalar",
    html: `…`, // jadval layout, tugma, "Tugma ishlamasa: {{havola}}"
    matn: "…", // havola va "Siz so'ramagan bo'lsangiz …"
  },
  eslatma: {
    mavzu: "Eslatma: {{matn}}",
    html: `<!doctype html><html lang="uz"><head>
<meta charset="utf-8"></head>
<body style="font-family:Arial,sans-serif">
<p>Vazifa muddati yaqin: <strong>{{matn}}</strong> — {{muddat}}.</p>
<p style="font-size:12px;color:#777">
<a href="{{obunaBekor}}">Eslatmalarni o'chirish</a></p>
</body></html>`,
    matn:
      "Vazifa muddati yaqin: {{matn}} — {{muddat}}.\n\n" +
      "Eslatmalarni o'chirish: {{obunaBekor}}\n",
  },
} as const satisfies Record<string, Shablon>;
  • Bitta Shablon — uchta Handlebars funksiya: mavzu, HTML, matn. Faqat HTML ekranlanadi.
  • strict: true — har uchalasida.
  • as const satisfies Record<string, Shablon> — har shablon to'g'ri shaklda ekani tekshiriladi, nomlari esa aniq tur bo'lib qoladi (satisfies).
  • tasdiqlash HTML'ida {{havola}} ikki marta: tugmada va "Tugma ishlamasa" matnida. Ba'zi ilovalar tugmani ko'rsatmaydi — havola matnda ham bo'lishi kerak.

eslatma ni hujumchi uslubidagi vazifa nomi bilan render qildik (formatlangan modul nusxasi, Node 24.21.0):

js
const eslatma = shablonKompilyatsiya(SHABLONLAR.eslatma);
const x = eslatma({
  matn: "Non <olish> & choy",
  muddat: "28.10 18:00",
  obunaBekor: "https://vazifalar.example.uz/obuna/x1",
});
console.log(x.mavzu);
console.log(x.matn);
console.log(x.html.match(/<p>Vazifa.*<\/p>/)[0]);
eslatma({ matn: "Non olish", muddat: "28.10" }); // obunaBekor yo'q
text
Eslatma: Non <olish> & choy
Vazifa muddati yaqin: Non <olish> & choy — 28.10 18:00.

Eslatmalarni o'chirish: https://vazifalar.example.uz/obuna/x1

<p>Vazifa muddati yaqin: <strong>Non &lt;olish&gt; &amp; choy</strong> — 28.10 18:00.</p>
"obunaBekor" not defined in [object Object] - 6:11

Mavzu va matnda belgilar o'z holicha, HTML'da — ekranlangan. obunaBekor unutilsa — xato, bo'sh havolali xat emas.

Kanon testi (test/email.test.ts, birinchi test) aynan shu qoidalarni tekshiradi:

ts
test("shablon: {{}} ekranlaydi, matn versiyasi ekranlamaydi, " +
  "yo'q o'zgaruvchi — xato", () => {
  const t = shablonKompilyatsiya(SHABLONLAR.tasdiqlash);
  const x = t({
    ism: `<img src=x onerror=alert(1)> "Aziz"`,
    havola: "https://v.example/t?a=1&b=2",
  });
  // Handlebars `=` ni ham ekranlaydi
  assert.match(x.html,
    /Salom, &lt;img src&#x3D;x onerror&#x3D;alert\(1\)&gt; &quot;Aziz&quot;!/);
  assert.match(x.html,
    /href="https:\/\/v\.example\/t\?a&#x3D;1&amp;b&#x3D;2"/);
  // ekranlash href ichidagi sxemani TEKSHIRMAYDI —
  // havola faqat server yasagan (https) bo'lishi shart
  assert.match(t({ ism: "A", havola: "javascript:alert(1)" }).html,
    /href="javascript:alert\(1\)"/);
  assert.match(x.matn, /Salom, <img src=x onerror=alert\(1\)> "Aziz"!/);
  assert.throws(() => t({ ism: "A" }), /"havola" not defined/);
});

Ikkinchi assert qiziq: havoladagi & va = ham ekranlandi (a&#x3D;1&amp;b&#x3D;2). Brauzer va pochta ilovasi atributni o'qiyotganda ularni qaytadan ochadi — havola buzilmaydi. Formatlangan nusxani biz tsc, eslint va test bilan tekshirdik:

text
✔ shablon: {{}} ekranlaydi, matn versiyasi ekranlamaydi, yo'q o'zgaruvchi — xato (39.6647ms)
﹣ Mailpit orqali yuborish (0.6322ms) # MAILPIT_API yo'q

Mailpit testlari — oldingi darsdagidek, Docker'siz o'tkazildi; kanonda ular o'tgan (82/82). Modul tayyor — commit:

bash
git commit -m "feat(email): Pochta — SMTP va Handlebars shablonlari"

vazifalar-api ga ulash 28-qism tugagach: tasdiqlash xati ro'yxatdan o'tishda ketadi, havolani esa token bilan server yasaydi.

11. Real ishda

  • Transactional provayderlar (Postmark, SendGrid, Brevo) o'z panelida shablon saqlashni taklif qiladi: dizayner kodga tegmasdan tahrirlaydi, ilova faqat shablon ID va o'zgaruvchilarni yuboradi. Narxi — shablon provayderga bog'lanib qoladi.
  • React Email Next.js va TypeScript loyihalarida tez tarqalmoqda: xat ham komponent, test ham oddiy.
  • Ko'p tilli xat: har til uchun alohida shablon yoki tarjima kalitlari — Backend'da ko'p tillilik darsida.
  • Intervyuda: "Email'da XSS bormi?", "Matn versiyasi nega kerak?", "Email shablonlarini qanday test qilasiz?"

Xulosa

  • Shablon bir marta kompilyatsiya qilinadi, har xat uchun ma'lumot bilan chaqiriladi.
  • {{x}} ekranlanadi, {{{x}}} — yo'q; strict: true yo'q o'zgaruvchini xatoga aylantiradi.
  • Ekranlash javascript: ni to'xtatmaydi: havolani server yasaydi yoki new URL + ruxsat ro'yxati.
  • juice <style> ni inline qiladi, @media esa <style> da qoladi; MJML Outlook'ga mos jadval yasaydi.
  • React Email 6 — yagona react-email paketi; plainText matn versiyani beradi; render ~4 ms (birinchisi 474 ms).
  • Matn versiyasi va mavzu ekranlanmaydi; HTML — doim.

Keyingi dars: Email yetkazib berish (deliverability) — xat nega spam'ga tushadi: SPF, DKIM va DMARC'ni haqiqiy imzo va tekshiruv bilan ko'ramiz, List-Unsubscribe va bounce'larni o'rganamiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!