সরাসরি পর্বে যান
পর্ব ৬৩ · ৯৩-এর মধ্যে · মডিউল ৯

Email আর notification — transactional mail, SMS আর push

Email and Notifications — Transactional Mail, SMS and Web Push

Taught in Bangla, this lesson sends notifications from a queue, builds templates with React Email or MJML with escaping, explains the MIME inside a Bangla email with RFC 2047 subjects and quoted-printable, deliverability with SPF, DKIM and DMARC, bounces, suppression and one-click unsubscribe, providers and a local SMTP server, SMS segments and cost for Bangla text, web push with service workers and VAPID, and in-app notifications.

এই পর্বে যা শিখবেন

  • Transactional আর marketing মেইল আলাদা করতে, আর সব পাঠানো job queue দিয়ে চালাতে
  • React Email বা MJML দিয়ে template বানাতে, plain text বিকল্প দিতে, আর বাংলা subject-এর কাঁচা রূপ পড়তে
  • SPF, DKIM, DMARC বসাতে, bounce আর complaint সামলাতে, আর RFC 8058-এর one-click unsubscribe বানাতে
  • বাংলা SMS-এর segment আর খরচ হিসাব করতে, আর web push-এর পুরো পথ চালাতে
  • In-app notification, preference আর quiet hours দিয়ে বাজারঘরের “অর্ডার রওনা হয়েছে” খবর সব channel-এ পাঠাতে

১ · একটি ঘটনা, চারটি পথ: notification-এর মানচিত্র

পর্ব ৪৯-এ বাজারঘর Nodemailer দিয়ে email যাচাই আর password reset-এর মেইল পাঠিয়েছিল, নিজের কম্পিউটারের নকল SMTP server-এ। সেখানে একটি জিনিস বলা হয়েছিল কিন্তু পুরো দেখানো হয়নি: মেইল উত্তরের আগে নয়, পরে যায়। এই পর্বে সেই “পরে”-র পুরো জগৎ দেখব। একটি অর্ডার কুরিয়ারের হাতে গেল। ক্রেতা খবরটি পাবেন অ্যাপের ঘণ্টা-চিহ্নে, মেইলে, ফোনের push-এ, হয়তো SMS-এ। কোনটি কখন যাবে, আর কোনটি একদম যাবে না, সেটিই এই পর্বের বিষয়।

পাড়ার ডাকঘরের কথা ভাবুন। কাউন্টারে চিঠি জমা দিয়ে আপনি রসিদ নিয়ে চলে যান। ডাকপিয়ন আপনার সামনে দাঁড়িয়ে চিঠি বিলি করেন না। ভেতরে বাছাই হয়: রেজিস্ট্রি চিঠি এক থলেতে, সাধারণ চিঠি আরেক থলেতে, বিজ্ঞাপনের পুস্তিকা আলাদা। ঠিকানায় কেউ নেই বলে ফেরত এলে খাতায় লেখা হয়, যাতে বারবার না যায়। আর রাত এগারোটায় কেউ দরজায় কড়া নাড়েন না। Notification ব্যবস্থাও এই বাছাইঘর।

দুই জাতের মেইল

Transactional email হলো ক্রেতার নিজের কোনো কাজের ফলে যাওয়া একক মেইল: অর্ডারের রসিদ, “রওনা হয়েছে”, password reset, OTP. ক্রেতা এটি আশা করেন, আর না পেলে সাপোর্টে ফোন করেন। Marketing email একসাথে অনেককে যায়: ঈদের অফার, নতুন পণ্যের খবর। এর জন্য আলাদা সম্মতি লাগে, আর যেকোনো সময় বন্ধ করার উপায় দিতে হয়।

বিষয়TransactionalMarketing
কে শুরু করেক্রেতার কাজ বা অ্যাকাউন্টের ঘটনাদোকানের প্রচার
সম্মতিসেবার অংশ হিসেবে প্রত্যাশিতআলাদা opt-in
Unsubscribeসাধারণত দরকার নেই, কিন্তু বিজ্ঞাপন মেশালে লাগবেবাধ্যতামূলক, one-click সহ
দেরি সহ্যকম: OTP মিনিটের মধ্যে চাইবেশি: ঘণ্টা পরে গেলেও চলে
পাঠানোর ঠিকানাorders@bazarghar.examplenews@mail.bazarghar.example

শেষ সারিটি গুরুত্বপূর্ণ। বড় mail সেবা প্রেরকের domain আর IP-র একটি সুনাম রাখে। অফারের মেইলে অনেকে “spam” চাপলে সেই সুনাম নামে। একই domain থেকে গেলে তখন OTP-ও spam ফোল্ডারে পড়তে পারে। তাই অনেক দল অফারের জন্য আলাদা subdomain আর আলাদা message stream রাখে। এটি নিয়ম নয়, একটি প্রচলিত সাবধানতা।

কখনো অনুরোধের ভেতরে পাঠাবেন না

অর্ডারের route-এ await sendMail() লিখলে তিনটি সমস্যা হয়। SMTP ধীর হলে ক্রেতার পাতাও ধীর। SMTP বন্ধ থাকলে অর্ডারই ব্যর্থ দেখায়, অথচ টাকা কাটা হয়ে গেছে। আর ব্যর্থ মেইল আবার চেষ্টার কোনো জায়গা থাকে না। সমাধান হলো job queue: route শুধু একটি কাজ লিখে রাখে, worker পরে পাঠায়, ব্যর্থ হলে retry করে। Queue, retry, backoff আর cron-এর পুরো গল্প পর্ব ৬২-তে (Background job — queue, retry আর cron), BullMQ আর pg-boss দিয়ে। ঘটনাটি অর্ডারের সাথেই নিরাপদে লেখার কৌশল, transactional outbox, আছে পর্ব ৪৩-এ। আর বড় আকারে notification প্ল্যাটফর্মের নকশা আছে System Design কোর্সের notification system case study-তে। এখানে আমরা সেই নকশার application-এর অংশটি নিজের হাতে বানাব।

One event fans out to four channels through a queue and a router

ঘটনা থেকে চার channel, আর ফিরতি খবর

Notification pipeline: event, queue, router, channel আর feedback ঘটনা: order.shipped jobs টেবিল (queue) Router In-app Email Push SMS ফিরতি খবর BG-10421, কুরিয়ারে দেওয়া হলো unique(event, channel, user) preference · quiet hours suppression · push আছে কি PostgreSQL + SSE badge SMTP / provider DKIM সই push সেবা VAPID + এনক্রিপ্ট SMS gateway push না থাকলে bounce, complaint, 410 Gone → suppression, subscription মোছা

২ · Template: React Email, MJML আর escaping

মেইলের HTML ওয়েবপাতার HTML নয়। অনেক mail client <style> ছেঁটে ফেলে, flexbox বা grid বোঝে না, আর পুরোনো কিছু desktop client নিজের মতো করে পাতা আঁকে। তাই মেইলে এখনো <table> দিয়ে layout আর inline style চলে। হাতে এই HTML লেখা কষ্টের, তাই দুটি জনপ্রিয় পথ আছে। দুটিই ভালো; কোনটি নেবেন তা আপনার দলের অভ্যাসের প্রশ্ন।

React Email

JSX component দিয়ে মেইল লেখা, তারপর render() দিয়ে HTML. React জানা দলের জন্য সহজ। লেখার সময়ে @react-email/components 1.0.12 আর @react-email/render 2.2.0.

JSX → HTML

MJML

নিজস্ব markup (<mj-section>, <mj-button>), যা table-ভিত্তিক HTML-এ বদলায়। Framework-নিরপেক্ষ। লেখার সময়ে 5.4.1, আর এখানে mjml2html() একটি Promise ফেরায়।

markup → HTML
import { Html, Head, Preview, Body, Container, Heading, Text, Button, Hr, Link }
  from '@react-email/components';

export default function OrderShipped({ name, orderId, courier, trackUrl, unsubUrl }: Props) {
  return (
    <Html lang="bn">
      <Head />
      <Preview>অর্ডার {orderId} রওনা হয়েছে</Preview>
      <Body style={{ fontFamily: 'sans-serif', background: '#f6f7f9' }}>
        <Container style={{ maxWidth: 560, background: '#ffffff', padding: 24 }}>
          <Heading as="h1">আপনার অর্ডার রওনা হয়েছে</Heading>
          <Text>প্রিয় {name},</Text>
          <Text>অর্ডার {orderId} আজ {courier}-এর হাতে দেওয়া হয়েছে।</Text>
          <Button href={trackUrl}>পার্সেল কোথায় দেখুন</Button>
          <Hr />
          <Text>অফার মেইল চান না? <Link href={unsubUrl}>বন্ধ করুন</Link></Text>
        </Container>
      </Body>
    </Html>
  );
}
// render.tsx
const html = await render(<OrderShipped {...props} />);
const text = await render(<OrderShipped {...props} />, { plainText: true });

ল্যাবে ক্রেতার নামের জায়গায় ইচ্ছা করে একটি <script> বসানো হলো, যেন কেউ signup-এ নাম হিসেবে এটিই লিখেছেন।

সত্যিকারের run · React 19.3.1, @react-email/render 2.2.0, tsx 4.23.15, Node.js 24.15 · ~/bazarghar · সময় একটি local run-এর

html bytes  : 5007
text bytes  : 575
script tag? : false
name in html: প্রিয় <!-- -->রহিম &lt;script&gt;alert(1)&lt;/script&gt;<!-- -->,</p>
doctype     : <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//E
tables      : 2
render ms   : 47.1
--- plain text ---
আপনার অর্ডার রওনা হয়েছে

প্রিয় রহিম <script>alert(1)</script>,

অর্ডার BG-10421 আজ দ্রুত কুরিয়ার-এর হাতে দেওয়া হয়েছে।

পার্সেল কোথায় দেখুন https://bazarghar.example/track/BG-10421
…

চারটি জিনিস দেখুন। (১) React নিজেই <-কে &lt; করেছে, তাই HTML-এ কোনো script ঢোকেনি। (২) Plain text অংশে নামটি হুবহু আছে, আর সেটি ঠিক, কারণ সাদা লেখায় কোনো tag চলে না। (৩) JSX-এর ভেতরে কোনো <table> লেখা হয়নি, তবু ফলাফলে ২টি table, যা React Email নিজে বসিয়েছে। (৪) বোতামটি plain text-এ এসেছে লেখা আর লিংক মিলিয়ে। একই ধরনের MJML ফাইল (১১ লাইন) 5,778 byte HTML হলো, তাতে ৫টি table আর Outlook-এর জন্য ৫টি <!--[if mso মন্তব্য, error ০টি।

Plain text বিকল্প কেন

একটি মেইলে দুটি রূপ একসাথে যায়, multipart/alternative হয়ে: সাদা লেখা আর HTML. পাঠকের client যেটি ভালো দেখাতে পারে সেটি বাছে। কিছু মানুষ শুধু লেখা দেখেন, screen reader সাদা লেখা সহজে পড়ে, আর কিছু spam filter শুধু HTML-এর মেইলকে কম বিশ্বাস করে বলে জানা যায়। React Email আর MJML দুটিই HTML থেকে লেখা বের করতে পারে, তবে বের হওয়া লেখাটি একবার নিজে পড়ে দেখুন।

Escaping নিজের হাতে

React বা MJML ছাড়া template লিখলে escaping আপনার দায়িত্ব। নিচের ব্লকে একটি tagged template প্রতিটি মান escape করে বসায়। সাথে আরও দুটি ফাঁদ: লিংকে javascript:, আর subject-এ নতুন লাইন দিয়ে নতুন header ঢোকানো, যাকে বলে email header injection.

const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ESC[c]);
// tagged template: লেখা অংশ যেমন আছে, প্রতিটি ${মান} escape হয়ে বসে
const html = (parts, ...vals) => parts.reduce((s, p, i) => s + p + (i < vals.length ? esc(vals[i]) : ''), '');
const naive = (parts, ...vals) => parts.reduce((s, p, i) => s + p + (i < vals.length ? vals[i] : ''), '');
const safeUrl = (u) => (/^https:\/\//.test(u) ? u : 'https://bazarghar.example/');
const oneLine = (s) => String(s).replace(/[\r\n]+/g, ' ').slice(0, 120);   // header-এ নতুন লাইন নয়

const order = {
  name: 'রহিম <img src=x onerror=alert(1)>',
  note: 'দরজার "পাশে" রাখবেন & ফোন দেবেন',
  id: 'BG-10421\r\nBcc: everyone@example.com',
  track: 'javascript:alert(1)',
};
const tpl = (t, o) => t`<p>প্রিয় ${o.name},</p><p>নোট: ${o.note}</p><a href="${safeUrl(o.track)}">ট্র্যাক</a>`;

console.log('naive :', tpl(naive, order));
console.log('safe  :', tpl(html, order));
console.log('tags in naive:', (tpl(naive, order).match(/<img/g) || []).length,
            ' tags in safe:', (tpl(html, order).match(/<img/g) || []).length);

const badSubject = `বাজারঘর: অর্ডার ${order.id}`;
const goodSubject = `বাজারঘর: অর্ডার ${oneLine(order.id)}`;
console.log('subject lines naive:', badSubject.split(/\r?\n/).length, '-> second line:', badSubject.split(/\r?\n/)[1]);
console.log('subject lines safe :', goodSubject.split(/\r?\n/).length, '->', goodSubject);

const text = `প্রিয় ${order.name},\nনোট: ${order.note}`;   // plain text-এ HTML escape নয়
console.log('text  :', JSON.stringify(text));

tags in naive: 1 tags in safe: 0: escape ছাড়া <img onerror> HTML-এ ঢুকে গেছে; অনেক mail client script চালায় না, কিন্তু একই template ওয়েবের “মেইল দেখুন” পাতায় দেখালে এটি XSS. subject lines naive: 2 -> second line: Bcc: everyone@example.com: অর্ডার নম্বরের জায়গায় \r\n থাকলে নতুন একটি header জন্মায়। পরিষ্কার করার পরে লাইন ১টি। শেষ লাইনে plain text-এ HTML escape নেই, কারণ সেখানে &lt; লিখলে পাঠক ওই অদ্ভুত লেখাই দেখবেন। Nodemailer 10.3.1-এ একটি আলাদা run-এ \r\nBcc: ভরা subject পাঠিয়ে দেখা গেছে, library নিজেই তাকে এক লাইনে মিলিয়েছে আর প্রাপক ছিলেন একজনই। তবু নিজের হাতে বানানো header-এ এই নিশ্চয়তা নেই।

Mail client-এ কেমন দেখাবে, সাবধানে বলুন। একই HTML বিভিন্ন client-এ আলাদা দেখায়, আর কোন client কোন CSS মানে তা সময়ের সাথে বদলায়। বাংলা লেখা সাধারণত ফোন আর কম্পিউটারের নিজের font-এ দেখায়; web font অনেক client-এ চলে না বলে ধরে নিন। পাঠানোর আগে কয়েকটি আসল client-এ নিজের পরীক্ষা-ঠিকানায় দেখে নিন, আর caniemail.com-এর মতো তালিকা মিলিয়ে নিন। এই ল্যাবে কোনো আসল client-এ পরীক্ষা হয়নি।

৩ · বাংলা মেইলের ভেতরটা: MIME, RFC 2047 আর quoted-printable

১৯৮২ সালের SMTP-র মেইল ছিল শুধু ৭-bit ASCII লেখা। আজও মেইলের header-এ নিয়মমতো শুধু ASCII চলে। তাহলে “বাজারঘর” কীভাবে যায়? উত্তর হলো MIME, ১৯৯২ সালে প্রথম এসে ১৯৯৬ সালে RFC 2045–2049 হয়ে স্থির হওয়া এক গুচ্ছ নিয়ম। Header-এর জন্য RFC 2047-এর encoded word, আর body-র জন্য base64 বা quoted-printable.

পর্ব ৪৯-এর সেই নকল SMTP server এবার শুধু মেইল গুছিয়ে রাখেনি, কাঁচা byte-ও জমা রেখেছে। নিচে Nodemailer-এর তৈরি আসল কাঁচা মেইলের header অংশ, DKIM সই সহ। ঠিকানা কল্পিত, key এই ল্যাবেই বানানো আর ফেলে দেওয়া।

সত্যিকারের run · Nodemailer 10.3.1 → smtp-server 3.21.3, সব 127.0.0.1-এ · কাঁচা মেইল 13,498 byte · সই আর base64 ছাঁটা

DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=bazarghar.example;
 q=dns/txt; s=s2026; bh=SaMWh0+xCS4IKLcCXQtjeAVL3BP0kHPgqIl+QIOSmYw=;
 h=from:subject:date:message-id:to:mime-version:content-type:list-unsubscribe;
 b=F4IvFyz8irgiUVJHXWVBroZA27cCkwgI5EOPpBsnARgWYdLqiMVKEkNuV5Ui9gYsJrX+Jr0eP
 …
List-Unsubscribe-Post: List-Unsubscribe=One-Click
From: =?UTF-8?B?4Kas4Ka+4Kac4Ka+4Kaw4KaY4Kaw?= <orders@bazarghar.example>
To: rahim@example.com
Subject: =?UTF-8?B?4Kas4Ka+4Kac4Ka+4Kaw4KaY4KawOiDgpoXgprA=?=
 =?UTF-8?B?4KeN4Kah4Ka+4KawIEJHLTEwNDIxIOCmsOCmkw==?=
 =?UTF-8?B?4Kao4Ka+IOCmueCmr+CmvOCnh+Cmm+Cnhw==?=
Message-ID: <bg-10421-shipped@bazarghar.example>
List-Unsubscribe: <https://bazarghar.example/u/SAMPLE> (unsubscribe)
MIME-Version: 1.0
Content-Type: multipart/alternative;
 boundary="--_NmP-0d5c6b8aeb9bf11f-Part_1"

----_NmP-0d5c6b8aeb9bf11f-Part_1
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: base64

4KaG4Kaq4Kao4Ka+4KawIOCmheCmsOCnjeCmoeCmvuCmsCDgprDgppPgpqjgpr4g4Ka54Kav4Ka8
…
----_NmP-0d5c6b8aeb9bf11f-Part_1
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir=3D"ltr" =
…
=E0=A6=AA=E0=A7=8D=E0=A6=B0=E0=A6=BF=E0=A6=AF=
=E0=A6=BC <!-- -->=E0=A6=B0=E0=A6=B9=E0=A6=BF=E0=A6=AE<!-- -->,</p>

পড়ে দেখুন। =?UTF-8?B?…?= মানে: charset UTF-8, encoding B (base64), মাঝে লেখা। “বাজারঘর” ৭টি অক্ষর, প্রতিটি UTF-8-এ ৩ byte, মোট ২১ byte, base64-এ ঠিক ২৮ অক্ষর: 4Kas…4Kaw. লম্বা subject তিনটি টুকরোয় ভাঙা, প্রতিটি নতুন লাইনে একটি ফাঁকা দিয়ে শুরু, যাকে বলে header folding. পাঠকের client টুকরোগুলো জুড়ে আবার পুরো বাংলা দেখায়।

Body-তে Nodemailer দুই অংশে দুই রকম encoding নিয়েছে। সাদা লেখা প্রায় পুরোটাই বাংলা, তাই base64 ছোট হয়। HTML-এ বেশিরভাগ ASCII tag, তাই quoted-printable: ASCII অক্ষর যেমন আছে থাকে, বাকি প্রতিটি byte =XX, আর লাইনের শেষে = মানে “লাইন আসলে ভাঙেনি”। =3D হলো = চিহ্ন নিজে। বাংলার প্রতিটি অক্ষর এখানে ৯ ঘর নেয় (=E0=A6=AA), base64-এ গড়ে ৪. তাই সব বাংলা লেখায় quoted-printable খারাপ বাছাই। নিচের ব্লকে নিজে encoded word বানিয়ে আবার ফেরত আনুন।

// RFC 2047: header-এ শুধু ASCII চলে, তাই বাংলা যায় "encoded word" হয়ে
function encodeB(text, max = 66) {           // "Subject: " সহ লাইন ≤ 76
  const words = []; let cur = '';
  for (const ch of text) {                      // অক্ষর ভেঙে UTF-8 byte আলাদা হবে না
    const next = cur + ch;
    const len = 12 + Math.ceil(Buffer.byteLength(next) / 3) * 4;   // =?UTF-8?B??= = ১২ অক্ষর
    if (len > max && cur) { words.push(cur); cur = ch; } else cur = next;
  }
  words.push(cur);
  return words.map((w) => `=?UTF-8?B?${Buffer.from(w).toString('base64')}?=`).join('\r\n ');
}
function encodeQ(text) {                        // Q রূপ: ASCII অক্ষর পড়া যায়, বাকি =XX
  const q = [...Buffer.from(text)].map((b) =>
    /[A-Za-z0-9!*+\-/]/.test(String.fromCharCode(b)) ? String.fromCharCode(b)
      : b === 32 ? '_' : '=' + b.toString(16).toUpperCase().padStart(2, '0')).join('');
  return `=?UTF-8?Q?${q}?=`;
}
function decode(header) {                       // পাশাপাশি encoded word-এর মাঝের ফাঁকা বাদ
  return header.replace(/\?=\s+=\?/g, '?==?').replace(/=\?UTF-8\?([BQ])\?([^?]*)\?=/gi, (_, e, s) =>
    e.toUpperCase() === 'B' ? Buffer.from(s, 'base64').toString('utf8')
      : Buffer.from(s.replace(/_/g, ' ').replace(/=([0-9A-F]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))), 'latin1').toString('utf8'));
}
const subject = 'বাজারঘর: অর্ডার BG-10421 রওনা হয়েছে';
const b = encodeB(subject), q = encodeQ(subject);
console.log('chars:', [...subject].length, ' utf-8 bytes:', Buffer.byteLength(subject));
console.log('Subject: ' + b);
console.log('B length:', b.replace(/\r\n /g, ' ').length, ' Q length:', q.length);
console.log('Q head  :', q.slice(0, 48) + '…');
console.log('B back  :', decode(b), decode(b) === subject);
console.log('Q back  :', decode(q), decode(q) === subject);
const raw = 'Subject: ' + b + '\r\nMIME-Version: 1.0\r\n';
console.log('ascii only:', /^[\x00-\x7F]*$/.test(raw), ' longest line:', Math.max(...raw.split('\r\n').map((l) => l.length)));

chars: 36 utf-8 bytes: 82: ৩৬টি অক্ষর কিন্তু ৮২ byte, কারণ বাংলা অক্ষর ৩ byte আর ইংরেজি ১. B length: 150 Q length: 234: বাংলায় B রূপ অনেক ছোট, তাই Nodemailer B নিয়েছে। Q রূপটি তুলনার জন্য এক টুকরায় রাখা হয়েছে; RFC 2047 অনুযায়ী একটি encoded word ৭৫ অক্ষরের বেশি হতে পারে না, তাই আসল মেইলে সেটিকেও ভাঙতে হতো। B back … true আর Q back … true: দুটিই নিখুঁত ফেরে। ascii only: true: header-এ একটিও non-ASCII byte নেই। আমাদের টুকরো ভাঙা হয়েছে অক্ষরের সীমায়, যাতে একটি বাংলা অক্ষরের ৩ byte দুই টুকরোয় ভাগ না হয়; RFC 2047 এটিই চায়।

আরও গভীরে। SMTP-র মূল নিয়ম এখন RFC 5321, মেইলের গঠন RFC 5322. RFC 6531/6532 (২০১২) মেইলে সরাসরি UTF-8 header আর বাংলা ঠিকানা (যেমন রহিম@উদাহরণ.বাংলা) চালানোর পথ দেয়, যাকে SMTPUTF8 বলে। তবে পথের প্রতিটি server সেটি সমর্থন না করলে মেইল ফেরে, তাই সাধারণ প্রেরকেরা এখনো encoded word-এ থাকেন। Base64 প্রতি ৩ byte-কে ৪ অক্ষর বানায়, মানে ৩৩% বাড়তি, আর লাইনে ৭৬ অক্ষরের সীমা রাখে।

৪ · মেইল পৌঁছানো: SPF, DKIM আর DMARC

SMTP-তে যে কেউ From: orders@bazarghar.example লিখতে পারেন। তাই গ্রহীতার server তিনটি প্রশ্ন করে, আর উত্তরগুলো আসে আপনার domain-এর DNS থেকে। তত্ত্ব, ইতিহাস আর আক্রমণের দিক আছে Cybersecurity কোর্সের DNS আর email নিরাপত্তা পর্বে। এখানে আমরা application-এর দিক দেখব: কী বসাতে হয়, আর আসল সই কেমন দেখায়। মেইল ঠিকমতো inbox-এ পৌঁছানোর এই সামগ্রিক বিদ্যাকে বলে deliverability.

SPF

SPF: “কোন IP থেকে আমার domain-এর মেইল যায়?” Envelope-এর প্রেরক domain-এর TXT record-এ তালিকা।

কোথা থেকে

DKIM

DKIM: প্রেরক server গোপন key দিয়ে header আর body-তে সই করে। গ্রহীতা DNS-এর public key দিয়ে মেলায়।

কেউ বদলায়নি

DMARC

DMARC: পাঠকের দেখা From-এর domain-এর সাথে SPF বা DKIM-এর অন্তত একটি মিলে পাস করতে হবে। না মিললে কী করতে হবে, নীতিও বলে দেয়।

মিল আর নীতি

উদাহরণ · DNS record, কল্পিত domain · IP 203.0.113.25 ডকুমেন্টেশনের জন্য সংরক্ষিত ঠিকানা

bazarghar.example.                   TXT "v=spf1 ip4:203.0.113.25 -all"
s2026._domainkey.bazarghar.example.  TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqh…" "…IDAQAB"
_dmarc.bazarghar.example.            TXT "v=DMARC1; p=none; rua=mailto:dmarc@bazarghar.example"

DKIM-এর record-এ দুটি উদ্ধৃত টুকরো কেন? ২০৪৮ bit RSA key-এর public অংশ base64-এ দিয়ে record বানালে আমাদের run-এ পুরো লেখা হলো ৪১০ অক্ষর। DNS-এর একটি TXT string-এ সর্বোচ্চ ২৫৫ অক্ষর, তাই ৪১০ লেখা অন্তত ২টি string-এ ভাঙতে হয় (২৫৫ + ১৫৫)। গ্রহীতা জুড়ে নেয়। অনেক DNS প্যানেল এটি নিজে করে, কিছু করে না।

Nodemailer দিয়ে DKIM সই

const mailer = nodemailer.createTransport({
  host: process.env.SMTP_HOST, port: 587, secure: false,      // STARTTLS আর login, production-এ
  auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
  dkim: {
    domainName: 'bazarghar.example',
    keySelector: 's2026',
    privateKey: process.env.DKIM_PRIVATE_KEY,                // কখনো repo-তে নয়
    headerFieldNames: 'From:Subject:Date:Message-ID:To:MIME-Version:Content-Type:' +
                      'List-Unsubscribe:List-Unsubscribe-Post', // অংশ ৫ দেখুন
  },
});

অনেক provider নিজেই DKIM সই করে, তখন এই option লাগে না; নিজের SMTP server চালালে লাগে। ল্যাবে একটি পরীক্ষার key এখানেই বানিয়ে সই করা হলো, তারপর mailauth 8.0.4 package দিয়ে গ্রহীতার মতো যাচাই করা হলো। DNS-এর উত্তর এল একটি নকল resolver থেকে, কোনো আসল DNS-এ যাওয়া হয়নি। চারটি পরিস্থিতি:

সত্যিকারের run · mailauth 8.0.4, নকল DNS · একই সই করা মেইল চারভাবে যাচাই

from allowed ip   spf=pass dkim=pass    dmarc=pass
from other ip     spf=fail dkim=pass    dmarc=pass
body changed      spf=pass dkim=neutral dmarc=pass  | body hash did not verify
other ip+changed  spf=fail dkim=neutral dmarc=fail  | body hash did not verify

দ্বিতীয় লাইনটিই DMARC-এর মূল কথা। মেইল অন্য IP থেকে এলে SPF ব্যর্থ, যেমন ঘটে কেউ মেইল forward করলে। কিন্তু DKIM সই অক্ষত, আর সেটি From-এর domain-এর সাথে মেলে, তাই DMARC পাস। তৃতীয় লাইনে body-র একটি সংখ্যা বদলানো হয়েছে (BG-10421 থেকে BG-10422): সইয়ের bh= আর মেলে না। শেষ লাইনে দুটিই ব্যর্থ, তাই DMARC ব্যর্থ, আর গ্রহীতা নীতি অনুযায়ী মেইলটি সরিয়ে রাখবেন বা ফেরাবেন।

DMARC ধাপে ধাপে

প্রথমে p=none, শুধু রিপোর্ট নেওয়া। rua ঠিকানায় বড় mail সেবাগুলো প্রতিদিনের সারাংশ পাঠায়: কোন IP থেকে আপনার domain-এর নামে কত মেইল গেল, কতগুলো পাস করল। কয়েক সপ্তাহ দেখে সব বৈধ উৎস (দোকানের সার্ভার, newsletter সেবা, হিসাবের সফটওয়্যার) ঠিক হলে p=quarantine, শেষে p=reject. হঠাৎ reject দিলে নিজের ভুলে বসানো কোনো সেবার বৈধ মেইলও হারাবেন। ২০২৪ সাল থেকে Google আর Yahoo দিনে প্রায় ৫,০০০-এর বেশি মেইল পাঠানো প্রেরকদের কাছে SPF, DKIM, অন্তত p=none-এর DMARC আর অফারের মেইলে one-click unsubscribe চায়। Google-এর সাহায্য-পাতা spam হার ০.৩%-এর নিচে রাখতে বলে। এই নিয়ম বদলাতে পারে, তাই পাঠানোর আগে বর্তমান পাতাটি দেখে নিন।

SPF ২০০৬ সালে RFC 4408 হয়ে আসে, ২০১৪ সালে RFC 7208. DKIM জন্মায় দুটি আলাদা প্রস্তাব মিলে, Yahoo-র DomainKeys আর Cisco-র Identified Internet Mail, ২০০৭ সালে RFC 4871 আর ২০১১ সালে RFC 6376. DMARC ২০১২ সালে কয়েকটি বড় mail আর আর্থিক প্রতিষ্ঠানের জোট প্রকাশ করে, ২০১৫ সালে RFC 7489 হয়ে informational মান পায়। ২০২৬ সালের মে মাসে RFC 9989 একে Standards Track-এ, Proposed Standard হিসেবে, এনেছে আর 7489 প্রতিস্থাপন করেছে; রিপোর্টের নিয়ম আলাদা হয়েছে RFC 9990 আর 9991-এ। v=DMARC1 record আগের মতোই চলে বলে জানা যায়, তবে কিছু tag বদলেছে, তাই নতুন record লিখতে মূল RFC দেখুন।

৫ · Bounce, complaint, suppression আর one-click unsubscribe

পাঠানো মানেই পৌঁছানো নয়। মেইল ফিরে আসাকে বলে bounce. দুই রকম: hard bounce, ঠিকানাই নেই (550 5.1.1); soft bounce, সাময়িক সমস্যা, যেমন বাক্স ভরা বা server ব্যস্ত (4xx). আর ক্রেতা “spam” চাপলে mail সেবা প্রেরককে একটি complaint পাঠাতে পারে, যদি প্রেরক তার feedback loop-এ যুক্ত থাকেন। Provider ব্যবহার করলে এই দুটি খবর আসে webhook হয়ে।

নিয়ম সহজ: hard bounce আর complaint হলে ঠিকানাটি suppression list-এ যায়, আর পরের প্রতিটি পাঠানোর আগে সেই তালিকা দেখা হয়। নেই এমন ঠিকানায় বারবার পাঠালে mail সেবা আপনাকে spammer ভাবতে শুরু করে, কারণ spammer-রাই পুরোনো কেনা তালিকায় পাঠান। ল্যাবের নকল SMTP-কে বলা হয়েছিল gone@example.com প্রত্যাখ্যান করতে।

সত্যিকারের run · smtp-server-এর onRcptTo-তে 550 · Nodemailer 10.3.1-এর error

{"code":"EENVELOPE","responseCode":550,
 "response":"550 5.1.1 <gone@example.com>: mailbox does not exist",
 "rejected":["gone@example.com"]}
  1. Workerjobমেইল পাঠায়
  2. SMTP550ঠিকানা নেই
  3. suppressionsINSERTতালিকায়
  4. পরের বারskipপাঠানো নয়
Jobনমুনা
job 41  channel=email  to=gone@example.com
subject: বাজারঘর: অর্ডার BG-10450 রওনা হয়েছে
suppressions-এ আছে? না
SMTP-র উত্তরসত্যিকারের run-এর লাইন
RCPT TO:<gone@example.com>
550 5.1.1 <gone@example.com>: mailbox does not exist
err.responseCode = 550   → hard
Databaseনমুনা SQL
INSERT INTO suppressions (email, scope, reason)
VALUES ('gone@example.com', 'all', 'hard_bounce:550 5.1.1')
ON CONFLICT (email) DO NOTHING;
job 41 → status = 'failed_permanent', retry নয়
পরের ঘটনানমুনা
order.shipped BG-10477 → user 9
inapp:0   email: বাদ (suppressed, all)
SMTP-তে কিছুই যায়নি
Soft bounceনমুনা
451 4.2.0 mailbox busy        → retry, backoff
৫ বার ব্যর্থ, ৩ দিন ধরে       → তবেই suppression
ক্রেতা নতুন ঠিকানা দিলেন      → সারি মুছে নতুন যাচাই
  1. পাঠানোর আগে দেখা। Worker job 41 নিল। ঠিকানাটি তালিকায় নেই, তাই পাঠাল।
  2. ৫xx মানে স্থায়ী। গ্রহীতার server 550 5.1.1 বলল। এই লাইনটি ল্যাবের আসল run-এর। 5 দিয়ে শুরু, তাই আবার চেষ্টা অর্থহীন।
  3. তালিকায় তোলা। ঠিকানা suppressions-এ গেল, scope all, কারণসহ। Job retry না করে স্থায়ী ব্যর্থ হলো।
  4. পরের ঘটনায় বাদ। নতুন অর্ডারে router তালিকা দেখে email বাদ দিল; in-app খবর তবু জমা হলো। (ধাপ ১, ৩, ৪ আর ৫-এর মান নমুনা, নকশা দেখানোর জন্য বানানো; শুধু ধাপ ২-এর লাইন আসল run-এর।)
  5. Soft bounce আলাদা। 4xx হলে backoff দিয়ে আবার চেষ্টা, কয়েক দিন ধরে বারবার ব্যর্থ হলে তবেই তালিকা। ক্রেতা ঠিকানা বদলালে সারিটি মুছে নতুন যাচাই।

মনে রাখুন, অনেক bounce পাঠানোর সময় নয়, পরে আসে। প্রথম server মেইল নিয়ে 250 বলল, তারপর ভেতরের server ঠিকানা না পেয়ে একটি ফেরত-মেইল (DSN) পাঠাল। তাই provider-এর webhook বা নিজের bounce ঠিকানা দুটোই পড়তে হয়। ওপরের run-এ ফেরত সঙ্গে সঙ্গে এসেছে, কারণ নকল server RCPT ধাপেই না বলেছে।

Unsubscribe: List-Unsubscribe আর one-click

অফারের মেইলে দুটি জিনিস লাগে: body-তে চোখে পড়ার মতো একটি লিংক, আর header-এ List-Unsubscribe. দ্বিতীয়টি থাকলে mail app নিজের “Unsubscribe” বোতাম দেখায়। ২০১৭ সালের RFC 8058 (John Levine আর Tobias Herkula) এর সাথে List-Unsubscribe-Post: List-Unsubscribe=One-Click যোগ করে, যাকে বলে one-click unsubscribe: বোতাম চাপলে mail সেবা আপনার URL-এ একটি POST পাঠায়, ক্রেতাকে কোনো পাতা খুলতে হয় না।

// উদাহরণ (এই রূপে চালানো হয়নি) · Express 5
app.post('/u/:token', async (req, res) => {
  // RFC 8058: body "List-Unsubscribe=One-Click", আসে multipart বা urlencoded রূপে
  // cookie, login বা redirect কিছুই থাকবে না; token নিজেই প্রমাণ।
  const sub = verifyUnsubToken(req.params.token);   // HMAC সই করা {email, list}
  if (sub) {
    await db.query(`INSERT INTO suppressions (email, scope, reason)
      VALUES ($1, $2, 'unsubscribe') ON CONFLICT (email) DO NOTHING`, [sub.email, sub.list]);
  }
  res.status(200).send('আপনাকে তালিকা থেকে সরানো হয়েছে।');  // ভুল token-এও একই উত্তর
});
app.get('/u/:token', (req, res) => res.send(confirmPage(req.params.token))); // GET-এ কিছু বদলাবে না

তিনটি সূক্ষ্ম কথা। প্রথম, GET-এ unsubscribe করবেন না। মেইলের নিরাপত্তা-স্ক্যানার লিংক খুলে দেখে, তাতে ক্রেতা না চাইতেই তালিকা থেকে বাদ পড়বেন; পর্ব ৪৯-এর যাচাই-লিংকেও একই কারণ ছিল। দ্বিতীয়, scope আলাদা রাখুন। অফার বন্ধ করা মানে রসিদ বা OTP বন্ধ নয়, তাই unsubscribe-এর scope marketing, আর hard bounce-এর all. তৃতীয়, RFC 8058 বলে এই দুটি header DKIM সইয়ের ভেতরে থাকতে হবে। ল্যাবে এখানেই একটি আসল ফাঁদ ধরা পড়ল:

সত্যিকারের run · Nodemailer 10.3.1-এর DKIM h= tag · প্রথমে default, তারপর headerFieldNames দিয়ে

default  h=from:subject:date:message-id:to:mime-version:content-type:list-unsubscribe
fixed    h=from:subject:date:message-id:to:mime-version:content-type:list-unsubscribe:list-unsubscribe-post

Nodemailer-এর default তালিকায় List-Unsubscribe আছে, কিন্তু List-Unsubscribe-Post নেই (library-র উৎস কোডেও তালিকাটি এমন)। তাই নিজে বসানো header সইয়ের বাইরে থেকে গেল। অংশ ৪-এর headerFieldNames সেটিই ঠিক করে। আপনার provider নিজে সই করলে তার docs-এ দেখুন সে কোন header সই করে।

৬ · Provider বাছাই আর local SMTP

নিজের mail server চালানো সম্ভব, কিন্তু IP-র সুনাম গড়া, PTR record, bounce পড়া আর blocklist থেকে বেরোনো একটি পূর্ণকালীন কাজ। তাই বেশিরভাগ দোকান একটি email provider নেয়। উদাহরণ হিসেবে কিছু নাম: Amazon SES, Postmark, Resend, SendGrid, Mailgun, Brevo. এদের কোনোটিকেই এই কোর্স সুপারিশ করে না, আর দাম ও সীমা সময়ে বদলায়, তাই এখানে দাম নেই। তুলনা করবেন এই প্রশ্নে:

প্রশ্নকেন জরুরি
SMTP আর HTTP API, দুটিই আছে?SMTP দিলে Nodemailer-এর কোড প্রায় বদলায় না, provider বদলানো সহজ
Bounce, complaint, delivery-র webhook আছে? সই করা?Suppression তালিকা এর ওপর চলে; সই না মেলালে যে কেউ নকল bounce পাঠাতে পারেন
Transactional আর marketing আলাদা stream বা IP?অফারের সুনামে OTP যেন না ডোবে
নিজের domain-এ DKIM, আর DMARC-এর মিল?নইলে provider-এর domain-এ সই হয়, From-এর সাথে মেলে না
Region আর ডেটা রাখার নীতিক্রেতার ঠিকানা আর মেইলের লেখা কোথায় কতদিন থাকে
Sandbox বা পরীক্ষার মোডউন্নয়নের সময় আসল মানুষকে মেইল না যাওয়া

উন্নয়নের সময় কোনো provider-ই লাগে না। পর্ব ৪৯ আর এই পর্বের মতো smtp-server দিয়ে নিজের নকল server, বা Mailpit-এর মতো তৈরি টুল, যা ব্রাউজারে মেইল দেখায়। কোডে শুধু config বদলায়: উন্নয়নে host: '127.0.0.1', production-এ provider-এর host, port 587 আর login, সবই environment variable থেকে (পর্ব ৩১)। Provider-এর key কখনো client-এর কোডে বা repo-তে যাবে না।

Provider-এর bounce আর complaint webhook একটি বাইরের HTTP অনুরোধ, তাই পর্ব ৫৭-এর HMAC webhook-এর নিয়ম এখানেও খাটে: সই যাচাই, একই event id দুবার এলে একবারই কাজ, আর দ্রুত 200 দিয়ে আসল কাজ queue-তে। নইলে যে কেউ একটি নকল “complaint” পাঠিয়ে আপনার সব ক্রেতাকে suppression তালিকায় তুলে দিতে পারেন, আর আপনি টেরও পাবেন না কেন রসিদ যাচ্ছে না। আর provider বদলানোর দিনটির কথা আগে থেকে ভাবুন: suppression তালিকা আপনার নিজের database-এ রাখুন, শুধু provider-এর ড্যাশবোর্ডে নয়, যাতে নতুন provider-এ গিয়ে মৃত ঠিকানায় আবার পাঠানো শুরু না হয়।

৭ · বাংলাদেশে SMS: segment, খরচ আর sender ID

বাংলাদেশে অনেক ক্রেতার কাছে email-এর চেয়ে ফোন নম্বরই আসল যোগাযোগ, তাই SMS প্রায় সব দোকানে থাকে। SMS পাঠানো হয় একটি SMS gateway-এর HTTP API দিয়ে: মোবাইল অপারেটরের নিজের bulk SMS সেবা, অথবা কোনো aggregator, যারা কয়েকটি অপারেটরকে এক API-তে আনে। কোন provider কী দাম নেয়, কোন নিয়মে চলে, তা চুক্তি আর BTRC-র বর্তমান নির্দেশনার ওপর নির্ভর করে। এই পর্ব কোনো নির্দিষ্ট দাম বা নিয়ম দাবি করে না; নিচের সব দাম স্পষ্টভাবে অনুমান।

কেন বাংলা SMS বেশি খরচের

একটি SMS-এর ভেতরে ১৪০ byte জায়গা। ইংরেজি পাঠানো হয় GSM-7 অক্ষর-তালিকায়, প্রতিটি অক্ষর ৭ bit, তাই ১৪০ × ৮ ÷ ৭ = ১৬০ অক্ষর। এই তালিকায় বাংলা নেই। একটিও বাংলা অক্ষর থাকলে পুরো বার্তা যায় UCS-2-তে, প্রতিটি অক্ষর ১৬ bit, তাই ১৪০ ÷ ২ = ৭০ অক্ষর। লম্বা বার্তা কয়েকটি segment-এ ভাঙে, আর প্রতিটি ভাগে ৬ byte যায় জোড়া লাগানোর তথ্যে (UDH), তাই GSM-7-এ ১৫৩ আর UCS-2-তে ৬৭ অক্ষর। Provider সাধারণত প্রতিটি ভাগের দাম নেয়।

// GSM 03.38 মূল তালিকা আর extension তালিকা (extension-এর প্রতিটি অক্ষর ২ septet)
const BASIC = '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?' +
  '¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
const EXT = '^{}\\[~]|€\f';

function segments(text) {
  const chars = [...text];
  const gsm = chars.every((c) => BASIC.includes(c) || EXT.includes(c));
  if (gsm) {
    const units = chars.reduce((n, c) => n + (EXT.includes(c) ? 2 : 1), 0);
    return { enc: 'GSM-7', units, parts: units <= 160 ? 1 : Math.ceil(units / 153) };
  }
  const units = text.length;                       // UTF-16 code unit, অর্থাৎ UCS-2-র ঘর
  return { enc: 'UCS-2', units, parts: units <= 70 ? 1 : Math.ceil(units / 67) };
}

const msgs = {
  en: 'Bazarghar: order BG-10421 has shipped with Druto Courier. Arrives in 2-3 days. Track: bazarghar.example/o',
  bn: 'বাজারঘর: অর্ডার BG-10421 দ্রুত কুরিয়ারে রওনা হয়েছে। ২-৩ দিনে পৌঁছাবে। দেখুন: bazarghar.example/o',
  bnShort: 'বাজারঘর: অর্ডার BG-10421 রওনা হয়েছে। bazarghar.example/o',
  enOneBn: 'Bazarghar: order BG-10421 has shipped with Druto Courier. Arrives in 2-3 days. ধন্যবাদ',
  enExt: 'Bazarghar [BG-10421]: paid {EUR} ~ 12 | ok',
};
for (const [k, t] of Object.entries(msgs)) {
  const s = segments(t);
  console.log(k.padEnd(8), s.enc, String(s.units).padStart(3), 'units ->', s.parts, 'SMS');
}
const RATE = 0.40;                                  // অনুমান: প্রতি SMS ভাগ ৳০.৪০
const daily = 20000;                                // অনুমান: দিনে ২০,০০০ বার্তা
for (const k of ['en', 'bn', 'bnShort']) {
  const p = segments(msgs[k]).parts;
  console.log(`cost/day ${k.padEnd(8)}: ${p} x ${daily} x ${RATE} = Tk ${(p * daily * RATE).toFixed(0)}`);
}

en GSM-7 105 units -> 1 SMS আর bn UCS-2 98 units -> 2 SMS: একই খবর, বাংলায় অক্ষর কম, কিন্তু ৭০-এর সীমা পেরিয়ে ২ ভাগ। bnShort … 57 units -> 1 SMS: কথা ছোট করলে বাংলাও এক ভাগে। enOneBn … UCS-2 86 units -> 2 SMS: ইংরেজি বার্তায় শুধু “ধন্যবাদ” যোগ করায় পুরোটাই UCS-2 হয়ে ২ ভাগ, যা প্রায়ই অজান্তে ঘটে। enExt … 48 units: [ ] { } ~ | GSM-7-এর বাড়তি তালিকায়, প্রতিটি ২ ঘর নেয়; লেখায় ৪২ অক্ষর, গোনায় ৪৮. শেষ তিন লাইন অনুমিত ৳০.৪০ দামে দৈনিক খরচ: বাংলা লম্বা রূপে ৳১৬,০০০, ছোট রূপে ৳৮,০০০.

তার মানে বাংলা বাদ নয়। অনেক ক্রেতার কাছে বাংলাই স্পষ্ট, আর ভুল বোঝা বার্তার খরচ বেশি। বরং বাংলা SMS ছোট রাখুন, লিংক ছোট রাখুন, আর ইংরেজি বার্তায় হঠাৎ একটি বাংলা শব্দ বা বাঁকা উদ্ধৃতি (’) যেন না ঢোকে। নিয়মিত পাঠানোর আগে template-এর segment সংখ্যা একটি test-এ যাচাই করুন। বাংলা সংখ্যা (২-৩) আর ইংরেজি সংখ্যা UCS-2-তে একই জায়গা নেয়।

Sender ID, delivery report আর OTP

  • Sender ID: ফোনে প্রেরক হিসেবে কী দেখাবে। বাংলাদেশে প্রচলিত কথায় “masking” মানে ব্র্যান্ডের নাম (যেমন BAZARGHAR), “non-masking” মানে একটি নম্বর। নাম দেখাতে সাধারণত নিবন্ধন আর অনুমোদন লাগে; কার কাছে, কত দিনে, কী কাগজে, তা provider আর নিয়ন্ত্রকের বর্তমান নিয়ম দেখে নিন।
  • Delivery report (DLR): gateway পরে webhook-এ জানায় বার্তা ফোনে পৌঁছাল কি না। “accepted” মানে শুধু gateway নিল, পৌঁছানো নয়, ঠিক SMTP-র 250-এর মতো।
  • OTP: পর্ব ৪৯-এ OTP-র নিরাপত্তা আর SMS pumping-এর হিসাব আছে। এখানে শুধু মনে রাখুন: OTP quiet hours মানবে না, কারণ ক্রেতা নিজেই এখন চেয়েছেন, আর OTP অফারের সাথে একই sender বা একই queue-র লাইনে আটকে থাকবে না।

৮ · Web push: service worker, Push API আর VAPID

Web push হলো ব্রাউজার বন্ধ থাকলেও ফোন বা কম্পিউটারে বাজারঘরের খবর দেখানো। তিনটি পক্ষ আছে: আপনার server, ব্রাউজার নির্মাতার push সেবা, আর ক্রেতার ব্রাউজারে চলা service worker. Service worker-এর জীবনচক্র আর cache আছে JavaScript কোর্সের PWA আর service worker পর্বে। এখানে শুধু push-এর অংশ।

ভাবুন ক্রেতা ডাকঘরে একটি লকার-বাক্স ভাড়া নিলেন, এটিই subscription. বাক্সের ঠিকানা আর একটি তালার চাবির ছাঁচ তিনি দোকানকে দিলেন। দোকান চিঠি সেই ছাঁচে তালা দিয়ে ডাকঘরে পাঠায়, আর খামে নিজের সিলমোহর দেয়, এটিই VAPID. ডাকঘর খাম খুলতে পারে না, শুধু সিল দেখে বাক্সে রাখে। চাবি আছে শুধু ক্রেতার কাছে।

ব্রাউজারের দিক

// উদাহরণ (ব্রাউজার লাগে, এই ল্যাবে চালানো হয়নি) · বোতাম চাপার পরে, প্রথম দেখাতেই নয়
async function enablePush() {
  const reg = await navigator.serviceWorker.register('/sw.js');
  if (await Notification.requestPermission() !== 'granted') return;
  const sub = await reg.pushManager.subscribe({
    userVisibleOnly: true,                       // প্রতিটি push-এ দৃশ্যমান notification
    applicationServerKey: VAPID_PUBLIC_KEY,      // শুধু public key
  });
  await fetch('/api/push/subscriptions', { method: 'POST',
    headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) });
}
// sw.js
self.addEventListener('push', (e) => {
  const d = e.data.json();
  e.waitUntil(self.registration.showNotification(d.title, { body: d.body, data: { url: d.url } }));
});
self.addEventListener('notificationclick', (e) => {
  e.notification.close();
  e.waitUntil(clients.openWindow(e.notification.data.url));
});

Push API-র subscribe() একটি push subscription ফেরায়: push সেবার একটি endpoint URL, ব্রাউজারের public key p256dh, আর একটি গোপন auth. Server এটি ক্রেতার account-এর সাথে টেবিলে রাখে। iPhone-এ Safari ১৬.৪ (২০২৩) থেকে web push চলে, তবে শুধু Home Screen-এ যোগ করা web app-এ; আচরণ ব্রাউজার ভেদে আলাদা, তাই নিজের লক্ষ্য-ব্রাউজারে পরীক্ষা করুন।

Server-এর দিক: আসল web-push, নকল push সেবা

আসল push সেবায় যাওয়া এই ল্যাবের নিয়মে নিষেধ। তাই 127.0.0.1-এ একটি HTTPS server push সেবার ভূমিকা নিল। ব্রাউজারের key জোড়া আর auth বানানো হলো Node.js-এর crypto দিয়ে, ঠিক যেমন ব্রাউজার বানায়। web-push 3.6.7-এর আসল sendNotification() পাঠাল, আর নকল সেবা VAPID সই মিলিয়ে, ব্রাউজারের গোপন key দিয়ে payload খুলে দেখল। VAPID key বানাতে npx web-push generate-vapid-keys, যা একটি public আর একটি private key ছাপে।

import webpush from 'web-push';
const opts = {
  vapidDetails: { subject: 'mailto:ops@bazarghar.example',
                  publicKey: process.env.VAPID_PUBLIC, privateKey: process.env.VAPID_PRIVATE },
  TTL: 3600, urgency: 'normal', topic: 'order-BG-10421',
};
try {
  await webpush.sendNotification(sub, JSON.stringify({ title, body, url }), opts);
} catch (e) {
  if (e.statusCode === 404 || e.statusCode === 410) await deleteSubscription(sub.endpoint);
  else throw e;                                   // অন্য ভুলে queue retry করবে
}

সত্যিকারের run · web-push 3.6.7, Node.js 24.15 · push সেবা 127.0.0.1-এর একটি নকল · কোনো আসল push সেবায় যাওয়া হয়নি

VAPID public  : 87 chars BAgCjy-QiCsst7TQ…
VAPID private : 43 chars (never sent to the browser)
subscription  : 192 bytes JSON, saved in push_subscriptions
payload       : 149 bytes
STANDIN headers: {"ttl":"3600","urgency":"normal","topic":"order-BG-10421","enc":"aes128gcm","len":"252"}
STANDIN vapid  : signature ok = true | aud = https://127.0.0.1:38633 | sub = mailto:ops@bazarghar.example | exp-in-h = 12.0
STANDIN body   : 252 bytes on the wire, first 8 = 09b3f0a0b284b97a
STANDIN decrypt: {"title":"অর্ডার রওনা হয়েছে","body":"BG-10421 · দ্রুত কুরিয়ার","url":"/orders/BG-10421"}
send #1       : 201
send #2       : 410 push subscription has unsubscribed or expired -> delete this subscription row

Run-এর headers লাইনে তিনটি option দেখা যাচ্ছে। TTL সেকেন্ডে: ফোন বন্ধ বা নেটওয়ার্কের বাইরে থাকলে push সেবা বার্তা কতক্ষণ ধরে রাখবে। “অর্ডার রওনা হয়েছে” এক ঘণ্টা পরে পেলেও কাজে লাগে, কিন্তু “ফ্ল্যাশ সেল ১০ মিনিটে শেষ” ১০ মিনিট পরে অর্থহীন, তাই তার TTL ছোট। Urgency (very-low, low, normal, high) ফোনকে ব্যাটারি বাঁচাতে সাহায্য করে; কম জরুরি বার্তা ফোন পরে একসাথে আনতে পারে। Topic একই বিষয়ের পুরোনো, এখনো না-পৌঁছানো বার্তাকে নতুনটি দিয়ে বদলে দেয়: অর্ডারের অবস্থা তিনবার বদলালে বন্ধ ফোন চালু হয়ে শুধু শেষটি পাবে। VAPID-এর JWT-তে exp-in-h = 12.0, অর্থাৎ web-push এই সইয়ের মেয়াদ ১২ ঘণ্টা রেখেছে; RFC 8292 সর্বোচ্চ ২৪ ঘণ্টা মানে।

  1. ব্রাউজারsubscribekey জোড়া
  2. বাজারঘরPOSTটেবিলে
  3. push সেবা201খুলতে পারে না
  4. Service workerpushদেখায়
ব্রাউজারsubscribe()
applicationServerKey = VAPID public (87 অক্ষর)
→ endpoint  https://127.0.0.1:38633/push/sub-7f3a
→ p256dh    ব্রাউজারের P-256 public key
→ auth      ১৬ byte গোপন
Serverpush_subscriptions
user_id=1  endpoint=…/push/sub-7f3a
JSON আকার: 192 bytes
একজন ক্রেতার একাধিক ডিভাইস = একাধিক সারি
web-pushপাঠানোর আগে
payload 149 bytes  → aes128gcm এনক্রিপ্ট
Authorization: vapid t=<JWT ES256>, k=<public>
TTL: 3600   Urgency: normal   Topic: order-BG-10421
push সেবানকল, local
signature ok = true   aud = https://127.0.0.1:38633
body 252 bytes, first 8 = 09b3f0a0b284b97a
লেখা পড়া যায় না → 201 Created
ব্রাউজারauth + গোপন key দিয়ে খোলা
{"title":"অর্ডার রওনা হয়েছে",
 "body":"BG-10421 · দ্রুত কুরিয়ার",
 "url":"/orders/BG-10421"}
showNotification(...)
অনুমতি তুলে নেওয়াপরের পাঠানো
410 push subscription has unsubscribed or expired
DELETE FROM push_subscriptions WHERE endpoint = $1
router: এখন push নেই → SMS-এর পথ খোলে
  1. Subscribe. ক্রেতা বোতাম চাপলেন, অনুমতি দিলেন। ব্রাউজার নিজের key জোড়া আর ১৬ byte auth বানাল, আর push সেবা থেকে একটি endpoint পেল।
  2. Server-এ জমা। ১৯২ byte-এর JSON ক্রেতার id-র সাথে টেবিলে গেল। ফোন আর ল্যাপটপ হলে দুটি সারি।
  3. এনক্রিপ্ট আর সই। web-push ১৪৯ byte payload-কে ব্রাউজারের p256dh আর auth দিয়ে aes128gcm-এ তালা দিল, আর VAPID private key দিয়ে ES256 JWT সই করল। TTL: 3600 মানে ফোন বন্ধ থাকলে push সেবা ১ ঘণ্টা ধরে রাখবে।
  4. Push সেবা। সে সই মিলিয়ে দেখল (true), কিন্তু ২৫২ byte-এর body তার কাছে অর্থহীন। উত্তরে 201.
  5. ব্রাউজারে খোলা। শুধু ব্রাউজারের গোপন key আর auth দিয়ে লেখা ফেরে। Service worker-এর push event notification দেখায়; চাপলে /orders/BG-10421 খোলে।
  6. 410 Gone. ক্রেতা অনুমতি তুলে নিলে push সেবা 410 দেয়। সারিটি মুছুন। বারবার মৃত endpoint-এ পাঠানো সময় নষ্ট, আর router এখন বুঝবে এই ক্রেতার push নেই।

হিসাব: ২৫২ byte কোথা থেকে

RFC 8291-এর aes128gcm শিরোনাম: salt ১৬ byte + record size ৪ + key id-র দৈর্ঘ্য ১ + সার্ভারের এককালীন public key ৬৫ = ৮৬ byte. তারপর লেখা ১৪৯ + padding চিহ্ন ১ + AES-GCM tag ১৬. মোট ৮৬ + ১৪৯ + ১ + ১৬ = ২৫২, run-এর len-এর সাথে হুবহু মেলে। RFC 8030 অনুযায়ী push সেবা অন্তত ৪,০৯৬ byte body নেবে বলে আশা করা যায়, তাই লেখার জন্য জায়গা প্রায় ৪,০৯৬ − ৮৬ − ১৭ = ৩,৯৯৩ byte; বাংলায় প্রতিটি অক্ষর ৩ byte, তাই মোটামুটি ১,৩০০ বাংলা অক্ষর। পুরো অর্ডার পাঠাবেন না; শিরোনাম, এক লাইন আর একটি লিংক যথেষ্ট, বাকিটা পাতা খুলে আসবে।

৯ · In-app notification, preference, quiet hours আর বাজারঘরের fan-out

অ্যাপের ঘণ্টা-চিহ্নের তালিকাটি সবচেয়ে সস্তা আর সবচেয়ে নির্ভরযোগ্য channel: কোনো provider নেই, কোনো অনুমতি লাগে না। এটি PostgreSQL-এর একটি টেবিল। লাল badge-এর সংখ্যা হলো না-পড়া সারির সংখ্যা। নতুন সারি এলে খোলা পাতায় খবর পৌঁছায় SSE দিয়ে; SSE-র পুরো গল্প, reconnect আর Last-Event-ID পর্ব ৬১-তে (SSE আর streaming) পাবেন।

CREATE TABLE notifications (
  id bigserial PRIMARY KEY,
  user_id int NOT NULL REFERENCES users,
  kind text NOT NULL, title text NOT NULL, body text NOT NULL, url text,
  created_at timestamptz NOT NULL DEFAULT now(),
  read_at timestamptz                                   -- NULL মানে না-পড়া
);
CREATE INDEX notifications_unread ON notifications (user_id) WHERE read_at IS NULL;

শেষ লাইনটি একটি partial index: শুধু না-পড়া সারি এতে থাকে, তাই কয়েক লাখ পুরোনো পড়া খবর থাকলেও badge গোনা ছোট থাকে। “পড়া হলো” করার UPDATE-এ user_id মেলানো বাধ্যতামূলক, নইলে যে কেউ অন্যের id দিয়ে খবর পড়া করে দিতে পারেন (পর্ব ৪৮-এর ownership যাচাই)। Worker নতুন সারি লিখে pg_notify ডাকে, আর Express-এর একটি LISTEN সংযোগ খোলা SSE stream-এ খবর ঠেলে দেয়। একাধিক server হলেও প্রতিটি নিজে LISTEN করে, তাই কোনো বাড়তি broker লাগে না। এর সীমা: NOTIFY-এর payload default-এ ৮,০০০ byte-এর কম, আর server বন্ধ থাকলে খবর জমা থাকে না; তাই আসল তথ্য থাকে টেবিলে, NOTIFY শুধু “দেখুন, নতুন কিছু এসেছে”।

Preference আর quiet hours

প্রতিটি ক্রেতা ঠিক করবেন কোন ধরনের খবর কোন পথে চান: অর্ডারের খবর push-এ, অফার শুধু মেইলে, রাত ১০টা থেকে সকাল ৮টা কোনো শব্দ নয়। এই quiet hours হিসাব হবে ক্রেতার time zone-এ, এখানে Asia/Dhaka, server-এর ঘড়িতে নয়। Server সাধারণত UTC-তে চলে, আর UTC-র রাত ১০টা ঢাকায় ভোর ৪টা। Intl দিয়ে তারিখের কাজ আছে পর্ব ৫৯-এ। নিচের router ঠিক করে কোন channel যাবে আর কত মিনিট পরে।

const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Dhaka', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
function dhakaMin(d) {                                  // ঢাকার ঘড়িতে দিনের কত মিনিট
  const p = Object.fromEntries(fmt.formatToParts(d).map((x) => [x.type, x.value]));
  return +p.hour * 60 + +p.minute;
}
function quietLeft(min, start, end) {                   // quiet hours শেষ হতে কত মিনিট বাকি
  const inside = start > end ? (min >= start || min < end) : (min >= start && min < end);
  return inside ? (end - min + 1440) % 1440 : 0;
}
const KIND = { 'order.shipped': { important: true }, 'promo.eid': { important: false } };
function route(kind, u, now) {
  const wait = quietLeft(dhakaMin(now), u.quietStart, u.quietEnd);
  const out = ['inapp:0'];                               // ঘণ্টা বাজে না, সবসময় জমা
  if (!KIND[kind].important && !u.marketing) return out; // অফারে আলাদা সম্মতি লাগে
  if (u.email && !u.suppressed) out.push('email:0');
  const pushOk = u.push && u.hasSub;
  if (pushOk) out.push('push:' + wait);
  if (u.sms && !pushOk && KIND[kind].important) out.push('sms:' + wait);
  return out;
}
const rahim = { email: true, sms: true, push: true, hasSub: true, marketing: false,
  suppressed: false, quietStart: 22 * 60, quietEnd: 8 * 60 };
const karim = { ...rahim, hasSub: false };
const cases = [
  ['rahim', rahim, 'order.shipped', '2026-11-03T08:05:00Z'],
  ['karim', karim, 'order.shipped', '2026-11-03T08:05:00Z'],
  ['karim', karim, 'order.shipped', '2026-11-03T17:30:00Z'],
  ['rahim', rahim, 'promo.eid', '2026-11-03T08:05:00Z'],
  ['rahim*', { ...rahim, suppressed: true }, 'order.shipped', '2026-11-03T01:59:00Z'],
];
for (const [name, u, kind, iso] of cases) {
  const d = new Date(iso);
  console.log(name.padEnd(6), kind.padEnd(13), 'Dhaka', fmt.format(d), '->', route(kind, u, d).join(' '));
}

rahim … 14:05 -> inapp:0 email:0 push:0: দিনের বেলা, push আছে, তাই SMS-এর দরকার নেই। karim … 14:05 -> … sms:0: করিমের push subscription নেই, তাই দামি SMS তবেই যায়। karim … 23:30 -> inapp:0 email:0 sms:510: রাত ১১:৩০, SMS ৫১০ মিনিট পরে, অর্থাৎ সকাল ৮:০০-এ; মেইল আর in-app এখনই, কারণ এরা শব্দ করে ঘুম ভাঙায় না। promo.eid … -> inapp:0: রহিম অফারে সম্মতি দেননি, তাই বাইরে কিছু যায় না। শেষ লাইনে suppression থাকায় email বাদ, আর ৭:৫৯-এ push ১ মিনিট পরে। একই Date UTC-তে দেওয়া, ঢাকার সময় Intl নিজে বের করেছে।

Preference কোথায় রাখবেন? এই ল্যাবে users টেবিলের কয়েকটি কলামে, যা ছোট দোকানের জন্য যথেষ্ট। খবরের ধরন বাড়লে একটি আলাদা টেবিল ভালো: প্রতিটি সারিতে ক্রেতা, খবরের ধরন (order, promo, security), channel আর চালু-বন্ধ। Preference পাতায় একটি ছক দেখান: সারিতে খবরের ধরন, কলামে channel, প্রতিটি ঘরে একটি switch. নিরাপত্তার খবর (নতুন ডিভাইসে login, password বদল) বন্ধ করার switch রাখবেন না, কারণ সেগুলো account রক্ষার অংশ। আর নতুন ক্রেতার default কী হবে, সেটিও একটি সিদ্ধান্ত: অর্ডারের খবর চালু, অফার বন্ধ, যতক্ষণ না তিনি নিজে সম্মতি দেন।

পুরো পথটি একবার, আসল run-এ

এবার সব জোড়া লাগানো হলো একটি Node.js ফাইলে: আসল PostgreSQL 18.6 (embedded-postgres, নিজের port আর নিজের database bazar_notif63), Express 5.3.0-এর SSE route, নকল SMTP, নকল SMS gateway আর অংশ ৮-এর নকল push সেবা। পর্ব ৬২-এ BullMQ বা pg-boss-এর মতো queue পাবেন; এখানে সব এক database-এ রাখতে একটি ছোট jobs টেবিল আর FOR UPDATE SKIP LOCKED, যাতে কয়েকটি worker একই job না নেয়। jobs-এ UNIQUE (event_id, channel, user_id) আছে, যা একটি idempotency key-এর কাজ করে। সময় নকল, তাই রাত আর সকাল দুটোই একই run-এ।

সত্যিকারের run · PostgreSQL 18.6, Express 5.3.0, Nodemailer 10.3.1, web-push 3.6.7 · সব 127.0.0.1-এ · নম্বর আর ঠিকানা কল্পিত

PG {"db":"bazar_notif63","port":38632,"v":"18.6"}
SSE 200 text/event-stream; charset=utf-8
# event 1 at 14:05 Dhaka
  plan order.shipped:BG-10421 user=1 inapp delay=  0m queued
  plan order.shipped:BG-10421 user=1 email delay=  0m queued
  plan order.shipped:BG-10421 user=1 push  delay=  0m queued
  plan order.shipped:BG-10421 user=2 inapp delay=  0m queued
  plan order.shipped:BG-10421 user=2 email delay=  0m queued
  plan order.shipped:BG-10421 user=2 sms   delay=  0m queued
  plan order.shipped:BG-10421 user=1 inapp delay=  0m duplicate, skipped
  …
  work job=1 inapp user=1 -> notification id=1
  work job=2 email user=1 -> 250 OK: message queued
  work job=3 push  user=1 -> push service 201
  work job=6 sms   user=2 -> sms gateway 200
# event 2 at 23:30 Dhaka
  plan order.shipped:BG-10433 user=2 sms   delay=510m queued
  work job=10 inapp user=2 -> notification id=3
  work job=11 email user=2 -> 250 OK: message queued
  still queued: [["sms","2026-11-04T02:00:00.000Z"]]
# worker at 08:00 Dhaka
  work job=12 sms   user=2 -> sms gateway 200
# read one
  POST read 200 {"updated":1}
  POST read as other user 200 {"updated":0}
# SSE frames received by রহিম
event: badge
data: 0

event: notification
id: 1
data: {"id":"1","title":"অর্ডার রওনা হয়েছে"}

event: badge
data: 1

event: badge
data: 0
  plan : Aggregate … -> Index Only Scan using notifications_unread on notifications … Index Cond: (user_id = 1)

কয়েকটি জিনিস চোখে রাখুন। একই ঘটনা দ্বিতীয়বার এলে তিনটি job-ই duplicate, skipped: queue বা webhook একই খবর দুবার দিলেও ক্রেতা দুটি মেইল পাবেন না। Job-এর id ৬ থেকে সোজা ১০-এ লাফাল, কারণ বাদ পড়া INSERT-গুলোও sequence-এর সংখ্যা খরচ করে; id-তে ফাঁক থাকা স্বাভাবিক। রাতের SMS-এর run_at UTC-তে 02:00Z, যা ঢাকায় সকাল ৮টা। অন্য ক্রেতার নামে “পড়া” করার চেষ্টায় updated: 0. আর badge গোনার plan partial index থেকেই উত্তর দিয়েছে। এবার একই ঘটনা ধাপে ধাপে দেখুন।

  1. ঘটনাBG-10421রওনা
  2. Routerplanpreference
  3. jobsuniquequeue
  4. Channel৪টিworker
Producerঅর্ডারের transaction-এ
event_id = order.shipped:BG-10421
user 1 রহিম: push আছে
user 2 করিম: push নেই
ঢাকায় দুপুর ২:০৫
Routerপ্রতি ক্রেতা
রহিম → inapp:0 email:0 push:0
করিম → inapp:0 email:0 sms:0
quiet hours ২২:০০–০৮:০০, এখন বাইরে
jobsএকই ঘটনা দুবার
6 rows queued
user=1 inapp … duplicate, skipped
user=1 email … duplicate, skipped
user=1 push  … duplicate, skipped
WorkerSKIP LOCKED
job=1 inapp → notification id=1
job=2 email → 250 OK: message queued
job=3 push  → push service 201
job=6 sms   → sms gateway 200
রহিমের খোলা পাতাSSE
event: badge          data: 0
event: notification   id: 1  "অর্ডার রওনা হয়েছে"
event: badge          data: 1
রাত ১১:৩০BG-10433, করিম
inapp:0  email:0  sms:510
run_at = 2026-11-04T02:00:00Z  (ঢাকায় ০৮:০০)
০৮:০০-এ worker → sms gateway 200
  1. একটি ঘটনা। অর্ডার BG-10421 কুরিয়ারে গেল। ঘটনার একটি স্থায়ী id আছে: order.shipped:BG-10421. দুই ক্রেতার জন্য খবর যাবে, এই ল্যাবে ঘটনাটিকে দুজনের নামেই পাঠানো হয়েছে।
  2. Router. রহিমের push আছে, তাই in-app, email, push. করিমের push নেই, তাই push-এর জায়গায় SMS. দুপুর বলে কোনো দেরি নেই।
  3. Queue আর idempotency. ৬টি job জমা হলো। একই ঘটনা আবার এলে রহিমের তিনটি job duplicate, skipped, কারণ UNIQUE (event_id, channel, user_id).
  4. Worker. প্রতিটি job আলাদা পাঠানো হলো: সারি, মেইল, push, SMS. একটি ব্যর্থ হলে শুধু সেটিই retry হবে, বাকিগুলো আবার যাবে না।
  5. খোলা পাতায় সরাসরি। রহিমের ব্রাউজারে SSE-তে প্রথমে badge 0, তারপর নতুন খবর আর badge 1. পরে পড়া করলে আবার badge 0.
  6. রাতের ঘটনা। রাত ১১:৩০-এ করিমের নতুন অর্ডার। মেইল আর in-app তখনই, SMS ৫১০ মিনিট পরে, সকাল ৮টায়। Run-এ ঠিক ০৮:০০-এ worker সেটি পাঠাল।

হিসাব: channel বাছাইয়ে খরচ কত বদলায়

ধরে নিই (সবই অনুমান): বাজারঘরে দিনে ২০,০০০ অর্ডার রওনা হয়, ৫৫% ক্রেতার push subscription আছে, আর SMS-এর দাম ভাগপ্রতি ৳০.৪০.

নীতিদিনে SMSভাগদিনে খরচ৩০ দিনে
সবাইকে SMS, লম্বা বাংলা২০,০০০২৳১৬,০০০৳৪,৮০,০০০
Push না থাকলে SMS, লম্বা বাংলা৯,০০০২৳৭,২০০৳২,১৬,০০০
Push না থাকলে SMS, ছোট বাংলা৯,০০০১৳৩,৬০০৳১,০৮,০০০

হিসাব: ২০,০০০ × (১ − ০.৫৫) = ৯,০০০ জন SMS পাবেন। ৯,০০০ × ২ × ০.৪০ = ৳৭,২০০, আর ছোট লেখায় অর্ধেক। প্রথম সারি থেকে শেষ সারিতে খরচ ৭৭.৫% কমেছে (১৬,০০০ − ৩,৬০০ = ১২,৪০০; ১২,৪০০ ÷ ১৬,০০০ = ০.৭৭৫), অথচ প্রত্যেক ক্রেতা খবরটি পেয়েছেন। কোনো কোড-অপ্টিমাইজেশন এত বড় সাশ্রয় দেয় না; এটি নকশার সিদ্ধান্ত। আর quiet hours খরচ বদলায় না, শুধু সময় বদলায়: রাতের SMS সকালে যায়।

Spam না করা: সংখ্যা কম, মূল্য বেশি

  • প্রয়োজনের মাপে channel: OTP আর নিরাপত্তার খবর সব পথে; “দাম কমেছে” শুধু in-app আর মেইলে। প্রতিটি নতুন channel ক্রেতার মনোযোগ আর আপনার খরচ।
  • একত্র করা (digest): এক ঘণ্টায় পাঁচটি পণ্যের দাম কমলে পাঁচটি push নয়, একটি সারাংশ।
  • সংখ্যার সীমা: প্রতি ক্রেতা দিনে কতটি push বা SMS যাবে, তার একটি ঊর্ধ্বসীমা রাখুন, আর ক্রেতা নিজে কমাতে পারবেন।
  • অনুমতি চাওয়ার সময়: প্রথমবার পাতা খুলতেই push-এর অনুমতি চাইবেন না। অর্ডার দেওয়ার পরে “অর্ডারের খবর push-এ চান?” জিজ্ঞেস করুন। কিছু ব্রাউজার বারবার প্রত্যাখ্যাত সাইটের অনুরোধ চুপচাপ দেখায় বলে জানা যায়।
  • ফেরার পথ সবসময়: প্রতিটি ধরনের খবরে বন্ধ করার স্পষ্ট উপায়, preference পাতায় এক জায়গায় সব।

১০ · AI-এর যুগে notification কোড

“অর্ডার রওনা হলে মেইল আর SMS পাঠাও” বললে AI কয়েক সেকেন্ডে কোড দেয়, আর প্রথম পরীক্ষায় তা চলে। সমস্যাগুলো দেখা দেয় পরে: বাংলা SMS-এর বিল দ্বিগুণ, অফারের মেইল spam-এ, রাত দুটোয় ক্রেতার ফোন বাজে।

AI প্রায়ই যা লেখেসমস্যাকীভাবে ধরবেন
Route-এর ভেতরে await sendMail()ধীর পাতা, retry নেইSMTP বন্ধ রেখে অর্ডার দেওয়া
Template-এ ${name} সরাসরি HTML-এHTML injection, XSS<script> নাম দিয়ে test
const {html} = mjml2html(src)MJML 5-এ Promise, html হয় undefinedনিজের version-এর docs, আসল run
শুধু HTML, text অংশ নেইAccessibility আর spam scoreকাঁচা মেইলে multipart/alternative খোঁজা
GET-এ unsubscribeস্ক্যানার নিজেই বাদ দেয়লিংক খুলে তালিকা দেখা
DKIM-এ default header তালিকাList-Unsubscribe-Post সইয়ের বাইরেh= tag পড়া
SMS দৈর্ঘ্য text.length <= 160বাংলায় সীমা ৭০, ভাগে ৬৭Segment গোনার test
new Date().getHours() >= 22Server-এর UTC ঘড়িনকল UTC সময়ে test
gcm-api-key বা পুরোনো push উদাহরণবাতিল হওয়া পথweb-push-এর README, VAPID
Private VAPID বা provider key client-এযে কেউ আপনার নামে পাঠাতে পারেনBundle-এ key খোঁজা

নিচের প্রম্পটে নিয়মগুলো আগে থেকে বলা, আর শেষে test চাওয়া। আসল key, ঠিকানা বা ফোন নম্বর দেবেন না।

আমি Node.js, Express 5 আর PostgreSQL দিয়ে একটি দোকানের notification ব্যবস্থা বানাচ্ছি। শর্তগুলো মেনে কোড দাও: ১. order.shipped ঘটনা jobs টেবিলে যাবে, UNIQUE(event_id, channel, user_id); route-এ কোনো পাঠানো হবে না। ২. Router: preference, Asia/Dhaka-র quiet hours (২২:০০–০৮:০০), suppression তালিকা; push না থাকলে তবেই SMS; OTP quiet hours মানবে না। ৩. Email: React Email template, plain text অংশ, Nodemailer DKIM-এ List-Unsubscribe আর List-Unsubscribe-Post সই করা। ৪. Hard bounce আর complaint-এ suppression (scope 'all'), unsubscribe-এ scope 'marketing'; one-click শুধু POST-এ, RFC 8058 মেনে। ৫. Web push: web-push package, VAPID key environment variable থেকে, 404/410-এ subscription মোছা। ৬. প্রতিটি package-এর version বলো, আর কোনো package বা option বানিও না। শেষে test লেখো: একই ঘটনা দুবার, রাত ২৩:৩০-এর SMS, বাংলা SMS-এর segment সংখ্যা, নামে <script> দিলে HTML কেমন হয়। কোন শর্ত তুমি নিশ্চিত করতে পারোনি, আলাদা করে বলো।

AI-এর লেখা notification কোড হাতে পেলে

পরীক্ষা ও ইন্টারভিউতে (বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় প্রায়ই আসে এমন ধরন):

  • Transactional আর marketing মেইলের পার্থক্য? একটি ক্রেতার কাজের ফল, অন্যটি প্রচার; দ্বিতীয়টিতে সম্মতি আর unsubscribe বাধ্যতামূলক।
  • SPF, DKIM, DMARC এক লাইনে? কোথা থেকে, কেউ বদলায়নি, From-এর সাথে মিল আর নীতি।
  • ১০০ অক্ষরের বাংলা SMS কয় ভাগ? UCS-2, ৭০-এর বেশি, তাই ⌈১০০ ÷ ৬৭⌉ = ২.
  • Push সেবা কি payload পড়তে পারে? না; RFC 8291-এ ব্রাউজারের key দিয়ে এনক্রিপ্ট করা থাকে।
  • একই মেইল দুবার যাওয়া ঠেকাবেন কীভাবে? ঘটনার স্থায়ী id দিয়ে unique key, at-least-once queue ধরে নিয়ে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১SPF তো আগে থেকেই ছিল। তবু DKIM আর তারপর DMARC কেন আনতে হলো? আর এখনো forward করা মেইল কেন মাঝে মাঝে আটকে যায়?

SPF শুধু একটি প্রশ্নের উত্তর দেয়: যে IP থেকে মেইল এল, সেটি envelope-এর প্রেরক domain-এর অনুমোদিত তালিকায় আছে কি না। এর দুটি বড় ফাঁক ছিল। প্রথমত, envelope-এর প্রেরক (MAIL FROM) আর পাঠক যে From দেখেন, দুটি আলাদা হতে পারে। প্রতারক নিজের domain-এ SPF ঠিক রেখে From-এ ব্যাংকের নাম লিখতেন, আর SPF পাস করত। দ্বিতীয়ত, কেউ মেইল forward করলে নতুন server-এর IP মূল domain-এর তালিকায় থাকে না, তাই বৈধ মেইলও ব্যর্থ হয়।

DKIM দ্বিতীয় সমস্যার উত্তর। সই থাকে মেইলের ভেতরে, তাই পথ বা IP বদলালেও সই টিকে থাকে, যতক্ষণ কেউ সই করা header আর body না বদলায়। Yahoo-র DomainKeys আর Cisco-র Identified Internet Mail মিলে এটি তৈরি হয়, আর ২০০৭ সালে RFC 4871 হয়ে মান পায়। কিন্তু DKIM-ও বলে না সই করা domain আর From-এর domain এক কি না। যে কেউ নিজের domain-এ সই করে From-এ অন্য নাম লিখতে পারতেন।

DMARC (২০১২-এ প্রকাশ, ২০১৫-এ RFC 7489, ২০২৬-এ RFC 9989) প্রথম সমস্যার উত্তর: “alignment”. SPF বা DKIM-এর অন্তত একটিকে From-এর domain-এর সাথে মিলে পাস করতে হবে। সাথে domain-এর মালিক নীতি জানান, আর রিপোর্ট পান। আমাদের run-এ ঠিক এটিই দেখা গেছে: অন্য IP থেকে SPF ব্যর্থ, কিন্তু DKIM অক্ষত বলে DMARC পাস।

তবু forward-এর সমস্যা পুরো যায়নি। মেইলিং-লিস্ট subject-এ “[list]” যোগ করে বা নিচে একটি লাইন জোড়ে, তাতে DKIM ভাঙে, আর নতুন IP-তে SPF-ও ভাঙে। এর জন্য ২০১৯ সালে ARC (RFC 8617) এসেছে, যেখানে মাঝের server নিজের যাচাইয়ের ফল সই করে পরের জনকে জানায়। গ্রহীতা সেই সাক্ষ্য কতটা বিশ্বাস করবেন, তা তাঁর নিজের সিদ্ধান্ত।

মূল উপলব্ধি: প্রতিটি মান আগেরটির একটি নির্দিষ্ট ফাঁক বন্ধ করেছে, আর নতুন ফাঁক খুলেছে। তাই application-এর দায়িত্ব তিনটিই ঠিকমতো বসানো, আর DMARC রিপোর্ট পড়ে দেখা কোন বৈধ উৎস এখনো বাদ পড়ছে।

প্র ০২বাংলা SMS-এ এক ভাগে মাত্র ৭০ অক্ষর কেন? এই সীমা কোথা থেকে এল, আর দোকান কি তবে Banglish-এ লিখবে?

সীমার শিকড় ১৯৮০-র দশকের GSM নকশায়। SMS তৈরি হয়েছিল ফোন নেটওয়ার্কের signalling চ্যানেলের ফাঁকা জায়গায়, যেখানে একটি বার্তার জন্য ১৪০ byte জায়গা ছিল। নকশাকারীরা ইউরোপের ভাষার কথা ভেবে একটি ৭-bit অক্ষর-তালিকা বানালেন (পরে 3GPP TS 23.038 নামে পরিচিত), যাতে ১৪০ byte-এ ১৬০ অক্ষর ধরে। বাংলা, আরবি, চীনা কোনো লিপিই সেই তালিকায় নেই।

অন্য লিপির জন্য পরে UCS-2 যোগ হলো, প্রতিটি অক্ষর ২ byte, তাই ১৪০ ÷ ২ = ৭০. লম্বা বার্তা জোড়া লাগাতে প্রতিটি ভাগের শুরুতে ৬ byte-এর একটি শিরোনাম (UDH) বসে, তাই ভাগপ্রতি ৬৭. আমাদের run-এ ৯৮ ঘরের বাংলা বার্তা ২ ভাগ, আর একই খবর ইংরেজিতে ১০৫ অক্ষরে ১ ভাগ। ভারত আর আরও কিছু দেশের জন্য 3GPP একটি “national language shift table” ব্যবস্থাও রেখেছে, যেখানে কিছু ভারতীয় লিপি ৭-bit-এ পাঠানো যায়; বাংলাদেশের নেটওয়ার্ক আর ফোনে এটি কতটা চলে, তা নিশ্চিত নয়, তাই এর ওপর নির্ভর না করাই নিরাপদ।

তাহলে কি Banglish? এটি একটি ব্যবসার সিদ্ধান্ত, শুধু খরচের নয়। Banglish-এর বানান মানুষভেদে আলাদা, বয়স্ক বা কম ইংরেজি জানা ক্রেতার কাছে কঠিন, আর OTP-র সাথে যে সতর্কবাণী (“কাউকে বলবেন না”) যায়, সেটি স্পষ্ট বাংলায় বেশি কার্যকর হতে পারে। আবার দিনে লাখ বার্তায় দ্বিগুণ খরচ সত্যিই বড়। মাঝের পথ হলো বাংলা রাখা কিন্তু ছোট করা: আমাদের “bnShort” ৫৭ ঘরে এক ভাগ। আর যে খবর push বা in-app-এ যেতে পারে, তা SMS-এ না পাঠানো।

মূল উপলব্ধি: চার দশক পুরোনো একটি নকশার সিদ্ধান্ত আজও বাংলা SMS-এর বিল ঠিক করে। প্রযুক্তির সীমা জানলে আপনি লেখার দৈর্ঘ্য, channel আর ভাষা নিয়ে তথ্যভিত্তিক সিদ্ধান্ত নিতে পারেন।

প্র ০৩Web push-এ ব্রাউজার নির্মাতার push সেবা মাঝখানে বসে। তাহলে তারা কি সব দোকানের সব খবর পড়তে পারে? VAPID আর এনক্রিপশন এখানে কী রক্ষা করে?

ফোনের ব্যাটারি বাঁচাতে প্রতিটি অ্যাপ বা সাইট নিজে সংযোগ খুলে রাখতে পারে না। তাই প্ল্যাটফর্মের একটি push সেবা একটি সংযোগ রাখে, আর সব পাঠানো তার মধ্য দিয়ে যায়। শুরুর দিকে Chrome-এর web push চলত Google-এর পুরোনো messaging সেবায়, আর server-কে একটি আলাদা API key লাগত। ২০১৬–১৭ সালে IETF তিনটি মান দেয়: RFC 8030 (push পাঠানোর HTTP নিয়ম), RFC 8291 (payload এনক্রিপশন) আর RFC 8292 (VAPID) নামে। এর ফলে একই কোড সব ব্রাউজারের push সেবায় চলে।

RFC 8291-এ payload এনক্রিপ্ট হয় ব্রাউজারের public key (p256dh) আর গোপন auth দিয়ে, যা শুধু ব্রাউজার আর আপনার server জানে। আমাদের নকল push সেবা ২৫২ byte পেয়েছে, কিন্তু ব্রাউজারের গোপন key ছাড়া সেটি খোলা যায় না। তবে push সেবা কিছু তথ্য দেখে: কখন, কোন endpoint-এ, কত বড় বার্তা গেল, আর Topic, TTL header. তাই Topic-এ ক্রেতার নাম বা অর্ডারের গোপন তথ্য রাখবেন না।

VAPID অন্য দিক রক্ষা করে। Endpoint URL কোনোভাবে ফাঁস হলে কেউ সেখানে বার্তা পাঠাতে পারতেন। Subscribe করার সময় ব্রাউজার আপনার VAPID public key push সেবাকে দেয়, আর তারপর শুধু সেই key-এর private অংশে সই করা অনুরোধই গৃহীত হয়। আমাদের run-এ নকল সেবা ES256 সই মিলিয়ে true পেয়েছে। sub claim-এ একটি যোগাযোগ ঠিকানা থাকে, যাতে সমস্যা হলে push সেবা প্রেরকের সাথে যোগাযোগ করতে পারে।

আরেকটি নিয়ম গোপনীয়তার: Chrome userVisibleOnly: true চায়, অর্থাৎ প্রতিটি push-এ ক্রেতা একটি দৃশ্যমান notification দেখবেন। নইলে সাইট চুপচাপ push পাঠিয়ে পেছনে কাজ চালাতে বা অবস্থান অনুমান করতে পারত। Apple ২০২৩ সালে iOS ১৬.৪-এ web push আনে, Home Screen-এ যোগ করা web app-এর জন্য।

মূল উপলব্ধি: মাঝের সেবা বার্তা বহন করে, পড়ে না; আর VAPID নিশ্চিত করে শুধু আপনিই আপনার ক্রেতাকে বার্তা পাঠাতে পারেন। তবু metadata দেখা যায়, তাই payload ছোট আর নিরীহ রাখুন।

প্র ০৪“প্রতিটি মেইল ঠিক একবারই যাবে” — এমন প্রতিশ্রুতি কি দেওয়া যায়? Queue, worker আর provider মিলিয়ে বাস্তবে কী নিশ্চিত করা সম্ভব?

সোজা উত্তর: পুরোপুরি নয়। ধরুন worker SMTP-তে মেইল দিল, provider 250 বলল, আর ঠিক তারপর worker-এর বিদ্যুৎ চলে গেল, job-কে “done” লেখার আগেই। Queue ভাবে কাজ হয়নি, তাই আবার দেয়। মেইল দুবার যায়। উল্টো দিকে, আগে “done” লিখে পরে পাঠালে, মাঝে পড়ে গেলে মেইল একবারও যায় না। দূরের একটি সেবার সাথে কাজ আর নিজের database-এর লেখা একসাথে atomic করা যায় না। তাই বেশিরভাগ queue “অন্তত একবার” দেয়, আর আমরা ঠিক একবারের কাছাকাছি যাই idempotency দিয়ে।

আমাদের ল্যাবে প্রথম ধাপটি দেখা গেছে: UNIQUE (event_id, channel, user_id) একই ঘটনা দুবার এলেও job একবারই বানায়। দ্বিতীয় ধাপ, পাঠানোর মুহূর্তে: কিছু provider-এর API-তে একটি idempotency key পাঠানো যায়, যাতে একই key-তে দ্বিতীয় অনুরোধ নতুন মেইল বানায় না; সব provider এটি দেয় না, তাই docs দেখুন। SMTP-তে এমন ব্যবস্থা নেই, তবে একই Message-ID রাখলে কিছু client দুটি কপি এক করে দেখায় বলে জানা যায়। তৃতীয় ধাপ, ঘটনাটি নিজেই নিরাপদে তৈরি হওয়া: অর্ডারের status বদল আর ঘটনা লেখা একই transaction-এ, পর্ব ৪৩-এর outbox.

Channel ভেদে ক্ষতিও আলাদা। দুটি “রওনা হয়েছে” মেইল বিরক্তিকর, কিন্তু নিরীহ। দুটি SMS মানে দ্বিগুণ খরচ। দুটি OTP মানে বিভ্রান্তি, কোনটি ঠিক? আর দুবার টাকা কাটা ভয়ংকর, তাই পর্ব ৬৫-এর payment-এ idempotency আরও কঠোর। In-app notification-এ সবচেয়ে সহজ, কারণ নিজের database-এ unique constraint দিয়ে পুরো নিশ্চয়তা পাওয়া যায়।

মূল উপলব্ধি: “ঠিক একবার” একটি নকশার লক্ষ্য, কোনো টুলের বৈশিষ্ট্য নয়। অন্তত একবার ধরে নিন, প্রতিটি ধাপে স্থায়ী id দিয়ে দ্বিতীয় চেষ্টাকে নিরীহ করুন, আর কোথায় নিশ্চয়তা নেই তা লিখে রাখুন।

প্র ০৫অফারের মেইল একই domain থেকে পাঠানো নিয়ে একজন বললেন, “আলাদা করার ঝামেলা কেন, সবই তো আমাদের মেইল।” আপনি কীভাবে বোঝাবেন, আর বড় mail সেবার ২০২৪ সালের নিয়ম এখানে কী বদলাল?

বড় mail সেবাগুলো প্রতিটি প্রেরক domain আর IP-র আচরণ দেখে একটি সুনাম গড়ে: কতজন মেইল খোলেন, কতজন “spam” চাপেন, কত মেইল ফেরে। Transactional মেইলে এই সংখ্যা সাধারণত খুব ভালো, কারণ ক্রেতা নিজেই অপেক্ষা করছেন। অফারের মেইলে স্বভাবতই খারাপ, কারণ সবাই আগ্রহী নন। দুটি একই domain আর IP থেকে গেলে গড় সুনাম নামে, আর একদিন password reset-ও spam-এ পড়ে। ক্রেতা তখন সাপোর্টে ফোন করেন, যার খরচ অফার থেকে পাওয়া লাভ খেয়ে ফেলতে পারে।

তাই প্রচলিত সাবধানতা হলো অফারের জন্য আলাদা subdomain (mail.bazarghar.example), provider-এ আলাদা stream বা IP, আর আলাদা bounce ও complaint হিসাব। DMARC-এ subdomain-এর জন্য আলাদা নীতিও দেওয়া যায়। এতে ঝুঁকি ভাগ হয়, পুরোপুরি দূর হয় না, কারণ মূল domain-এর নাম তো একই।

২০২৪ সালের ফেব্রুয়ারি থেকে Google আর Yahoo বড় প্রেরকদের জন্য নিয়ম কড়া করে: SPF আর DKIM দুটিই, অন্তত p=none-এর DMARC, From-এর সাথে মিল, অফারের মেইলে one-click unsubscribe আর body-তে দৃশ্যমান লিংক, আর spam হার একটি সীমার নিচে রাখা, Google-এর পাতায় সীমা ০.৩%. আগে এগুলো “ভালো অভ্যাস” ছিল, এখন না মানলে মেইল আটকে যেতে পারে। দোকানটি ছোট হলেও ঈদের এক দিনের প্রচারে সীমা পার হতে পারে।

আরেকটি দিক আইন আর আস্থা। অফার পাঠাতে সম্মতি লাগে, আর ক্রেতা বন্ধ করলে সেই সিদ্ধান্ত সম্মান করতে হয়; বাংলাদেশসহ বিভিন্ন দেশের আইন ও নীতি কী বলে, তা আইনজ্ঞের সাথে মিলিয়ে নিন। আলাদা stream থাকলে “অফার বন্ধ” করলে রসিদ বন্ধ হয় না, যা ক্রেতা আশা করেন।

মূল উপলব্ধি: মেইলের সুনাম একটি ভাগ করা সম্পদ। সবচেয়ে জরুরি মেইলকে সবচেয়ে ঝুঁকিপূর্ণ মেইল থেকে আলাদা রাখা প্রকৌশলের সিদ্ধান্ত, শুধু বিপণনের নয়।

অনুশীলন

প্রথমটি হিসাব, দ্বিতীয়টি নকশা-সমালোচনা, তৃতীয়টি ফল অনুমান, চতুর্থটি হাতে-কলমে।

  1. Numerical: (ক) ১৪৫ ঘরের একটি বাংলা SMS কয় ভাগে যাবে? (খ) ১৪৫ অক্ষরের ইংরেজি SMS-এ ৪টি [ বা ] থাকলে কয় ভাগ? (গ) দিনে ১৫,০০০ বার্তা, অনুমিত দাম ভাগপ্রতি ৳০.৪০ ধরে দুটির মাসিক (৩০ দিন) খরচ কত? (ঘ) Web push-এ ৩০০ byte payload পাঠালে aes128gcm-এ body কত byte হবে?

    (ক) UCS-2, ১৪৫ > ৭০, তাই ⌈১৪৫ ÷ ৬৭⌉ = ৩ ভাগ। (খ) ৪টি বাড়তি-তালিকার অক্ষর প্রতিটি ২ ঘর, তাই ১৪৫ + ৪ = ১৪৯ ঘর, যা ১৬০-এর মধ্যে, ১ ভাগ। (গ) বাংলা: ৩ × ১৫,০০০ × ০.৪০ × ৩০ = ৳৫,৪০,০০০; ইংরেজি: ১ × ১৫,০০০ × ০.৪০ × ৩০ = ৳১,৮০,০০০. বাংলা লেখাটি ৬৭ ঘরে নামাতে পারলে ৳১,৮০,০০০-এই নামবে। (ঘ) ৮৬ + ৩০০ + ১ + ১৬ = ৪০৩ byte.

  2. নকশা-সমালোচনা: একজন সহকর্মী লিখেছেন: app.post('/orders/:id/ship', async (req, res) => { await markShipped(id); await mailer.sendMail({ from: 'deals@bazarghar.example', to: user.email, subject: 'Shipped! Also 20% off today', html: `<p>Hi ${user.name}</p>` }); await sms.send(user.phone, longBanglaText); res.json({ ok: true }); });. রাত ১টায়ও চলে। কতগুলো সমস্যা পান?
    • Route-এর ভেতরে মেইল আর SMS: ধীর, retry নেই, SMTP বন্ধ থাকলে অর্ডারের পাতা ব্যর্থ। Job queue নিন।
    • markShipped আর খবর আলাদা: মাঝে পড়লে খবর হারায়। Outbox বা একই transaction-এ job লিখুন।
    • Transactional খবরে অফার মেশানো, অফারের ঠিকানা থেকে: সুনাম মিশে যায়, আর তখন unsubscribe লাগবে। আলাদা রাখুন।
    • ${user.name} escape নেই: HTML injection.
    • শুধু html, text অংশ নেই।
    • সবসময় SMS: push থাকলেও খরচ, আর লম্বা বাংলা মানে কয়েক ভাগ।
    • Quiet hours নেই: রাত ১টায় ফোন বাজে।
    • Preference আর suppression দেখা নেই, আর একই অনুরোধ দুবার এলে দুবার পাঠায়।
  3. ফল অনুমান: একজন ক্রেতার ৩টি না-পড়া notification আছে (id ৭, ৮, ৯)। তিনি SSE stream খুললেন। তারপর ক্রমে: (১) নতুন notification id ১০ এল, (২) তিনি id ৮ পড়া করলেন, (৩) অন্য একজন তাঁর id ৯ পড়া করার চেষ্টা করলেন, (৪) তিনি আবার id ৮ পড়া করলেন। ধরুন প্রতিটি “পড়া” অনুরোধের পরে একটি badge event যায়, আর UPDATE-এ user_id আর read_at IS NULL দুটিই মেলানো হয়। Stream-এ কোন কোন event আসবে, আর প্রতিটি updated কত?

    খোলামাত্র badge 3. (১) notification id 10, তারপর badge 4. (২) updated: 1, badge 3. (৩) অন্যজনের অনুরোধে updated: 0; badge event তাঁর নিজের stream-এ যায় (যদি খোলা থাকে), এই ক্রেতার stream-এ কিছু না, তাঁর badge ৩-ই থাকে। (৪) id ৮ আগেই পড়া, তাই updated: 0, আর badge 3 আবার আসে, সংখ্যা বদলায় না। শিক্ষা: read_at IS NULL শর্ত পুনরাবৃত্তিকে নিরীহ করে, আর ownership শর্ত অন্যের হাত থেকে বাঁচায়।

  4. হাতে-কলমে: নিজের কম্পিউটারে smtp-server চালিয়ে onData-তে কাঁচা stream জমা রাখুন, যেমন অংশ ৩-এ। Nodemailer-এ dkim option দিয়ে একটি পরীক্ষার key বানিয়ে বাংলা subject-এর একটি মেইল পাঠান। তারপর (ক) subject-এর encoded word হাতে decode করুন, (খ) h= tag-এ কোন header আছে দেখুন, (গ) npx web-push generate-vapid-keys চালিয়ে public key-এর দৈর্ঘ্য গুনুন।

    (ক) =?UTF-8?B? আর ?=-এর মাঝের অংশ base64 থেকে UTF-8-এ ফেরালে বাংলা আসবে; টুকরোগুলো জোড়ার সময় মাঝের ফাঁকা বাদ দিন। (খ) Nodemailer 10.3.1-এর default-এ list-unsubscribe-post থাকবে না; headerFieldNames দিয়ে যোগ করুন। (গ) আমাদের run-এ public key ৮৭ অক্ষর (base64url-এ ৬৫ byte), private ৪৩ অক্ষর, অর্থাৎ ৩২ byte. কোনো আসল ঠিকানায় পাঠাবেন না, host অবশ্যই 127.0.0.1 রাখুন, আর পরীক্ষার key কোথাও commit করবেন না।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রম“অর্ডার রওনা হয়েছে” খবরের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. অর্ডারের status বদল আর ঘটনা একই transaction-এ লেখা
  2. Router preference, quiet hours আর suppression দেখে channel বাছে
  3. প্রতি channel-এর জন্য unique key সহ job জমা
  4. Worker job নিয়ে মেইল, push বা SMS পাঠায়
  5. Bounce, complaint বা 410 এলে suppression আর subscription হালনাগাদ

একটি মেইলের header-এ আছে: Subject: =?UTF-8?B?4Kas4Ka+4Kac4Ka+4Kaw4KaY4Kaw?= — এখানে B কী বোঝায়?

  • Bangla charset
  • Quoted-printable encoding
  • Base64 encoding
  • Binary, অর্থাৎ কাঁচা byte

একটি মেইল অন্য একটি server থেকে forward হয়ে এল। SPF ব্যর্থ, কিন্তু From-এর domain-এ সই করা DKIM অক্ষত ও পাস। DMARC-এর ফল কী হবে?

  • ব্যর্থ, কারণ SPF আর DKIM দুটিই পাস করতে হয়
  • পাস, কারণ মিলে যাওয়া DKIM পাস করেছে
  • কোনো ফল নেই, কারণ SPF ব্যর্থ হলে DMARC চলে না
  • ব্যর্থ, কারণ forward করা মেইলে DKIM যাচাই হয় না

৯০ অক্ষরের একটি SMS-এ ৮৯টি ইংরেজি অক্ষর আর একটি বাংলা অক্ষর আছে। এটি কয়টি segment-এ যাবে?

  • ১টি, কারণ ৯০ < ১৬০
  • ২টি: ইংরেজি এক ভাগে, বাংলা আরেক ভাগে
  • ১টি, কারণ ৯০ < ১৫৩
  • ২টি, কারণ পুরো বার্তা UCS-2 হয়ে ৬৭-এর ভাগে

RFC 8058-এর one-click unsubscribe-এর জন্য কোনটি ঠিক?

  • POST অনুরোধে, cookie ছাড়া, আর List-Unsubscribe ও List-Unsubscribe-Post DKIM সইয়ের ভেতরে
  • লিংকের GET অনুরোধেই তালিকা থেকে বাদ
  • ক্রেতার login cookie দেখে তবেই বাদ
  • একটি নিশ্চিতকরণ পাতায় redirect করে সেখানে বাদ

web-push দিয়ে পাঠাতে গিয়ে push সেবা 410 Gone ফেরাল। Server-এর সবচেয়ে ঠিক কাজ কোনটি?

  • Exponential backoff দিয়ে retry
  • নতুন VAPID key বানিয়ে আবার পাঠানো
  • ওই subscription-এর সারি database থেকে মুছে দেওয়া
  • ক্রেতাকে মেইলে push-এর অনুমতি আবার দিতে বলা

Server UTC-তে চলে। রাত ২২:০০ থেকে সকাল ০৮:০০ (Asia/Dhaka, UTC+6) quiet hours. UTC 17:30-এ একটি SMS-এর অনুরোধ এল। কখন পাঠানো উচিত?

  • এখনই, কারণ UTC 17:30 সন্ধ্যা
  • ৫১০ মিনিট পরে, UTC 02:00-এ
  • UTC 08:00-এ
  • কখনো না, বাতিল করা

একই order.shipped ঘটনা queue থেকে দুবার এলে ক্রেতা যেন দুটি মেইল না পান, সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?

  • jobs টেবিলে UNIQUE (event_id, channel, user_id) আর ON CONFLICT DO NOTHING
  • Worker-এর মেমরিতে পাঠানো id-র একটি Set রাখা
  • পাঠানোর আগে SELECT করে দেখা আগে গেছে কি না
  • Queue-র retry বন্ধ করে দেওয়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. সব পাঠানো queue দিয়ে, ঘটনার স্থায়ী id সহ; route-এ কোনো await sendMail নয়।
  2. Template-এ escape, plain text অংশ; বাংলা subject যায় RFC 2047 encoded word হয়ে, body base64 বা quoted-printable-এ।
  3. SPF, DKIM, DMARC বসান, DMARC ধাপে ধাপে কড়া করুন; DKIM-এ List-Unsubscribe-Post সই হচ্ছে কি না দেখুন।
  4. Hard bounce আর complaint-এ suppression, unsubscribe শুধু POST-এ, scope সহ; বাংলা SMS-এ ৭০/৬৭ মনে রাখুন।
  5. Push-এ VAPID আর 410-এ মোছা; in-app টেবিলে partial index আর SSE; সব channel preference আর Asia/Dhaka-র quiet hours মেনে।

পরের পর্বে: ক্রেতা পণ্যের ছবি আপলোড করেন, বিক্রেতা ক্যাটালগ বানান। পরের পর্বে object storage, presigned URL আর ছবির আকার বদলের pipeline দিয়ে বাজারঘরের ফাইল রাখার ব্যবস্থা বানাব।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান