خادم النشرة البريدية عندي 1,258 سطرًا من node:sqlite

شغّلت نشرة هذا الموقع على خادم كتبته بنفسي في 1,258 سطرًا: تأكيد مزدوج، إلغاء اشتراك بنقرة واحدة، واجهة سطر أوامر للإرسال — وسقف تسليم معلَن بلا تجميل.

HUMANبقلم @mrsaynothing30d4 دقيقة قراءة

نشرة هذا الموقع تعمل على خادم كتبته بنفسي: 1,258 سطرًا من TypeScript وSvelte وSQL. تأكيد مزدوج، إلغاء اشتراك بنقرة واحدة، واجهة سطر أوامر للإرسال، ونقطة فحص للصحة. لا حساب على منصة، ولا فاتورة شهرية، وقائمة المشتركين ملف واحد أنسخه بـ rsync.

النشرة البريدية هي ثلاث نقاط نهاية، وجدولان، وترويسة رسالة تخفيها عنك أغلب المنصات.

هذه الترويسة هي ما جعل البناء قصيرًا. في فبراير 2024 جعلت Google وYahoo إلغاء الاشتراك بنقرة واحدة شرطًا على المُرسِلين بالحجم، عبر تطبيق RFC 8058 — ترويسات List-Unsubscribe وList-Unsubscribe-Post في كل رسالة. أصعب جزء في التسويق بالبريد صار ترويسة قياسية. وكل ما تضيفه المنصة المدفوعة حولها فهو إمّا هذا أو دفتر العناوين.

لماذا تستضيف النشرة بنفسك أصلًا؟

الجرد الصادق: القائمة صغيرة، والحجم رسالة واحدة لكل مقال، وخدمات الموقع الأخرى تعمل عندي أصلًا. استئجار منصة لهذا كان يعني تسليم العلاقة الوحيدة مع القرّاء التي أملكها فعلًا لطرف ثالث، مع صيغة تصدير اسمها مهذب ومواصفاته غامضة. كتبتها بنفسي: عصرٌ للأساس وعصرٌ للحواف.

هذا الخادم منصة مستضافة
أسطر الكود التي أستطيع قراءتها 1,258، كلها 0
ملكية القائمة ملف SQLite واحد زر تصدير
إلغاء الاشتراك ترويسة RFC 8058 بتوقيع محلي إعداد في لوحة التحكم
سقف التسليم ~5/10 على mail-tester حتى يتوافق DKIM سمعتها هي
الوقت حتى أول إرسال عطلة نهاية أسبوع أمسية

الجدول هو الصفقة كلها على شاشة واحدة. المنصة تتفوق بسمعة التسليم وبأنك لا تفكر؛ والملف يتفوق بالملكية وبإمكانية تدقيقه في محرر نصوص.

كيف يعمل التأكيد المزدوج بلا جلسة؟

ثلاثة ملفات تحمل التدفق كله. في db.ts جدولان — subscribers مع قيد CHECK يحبس status على pending أو confirmed أو unsubscribed، وsends لأثر التدقيق. ونقطة الاشتراك تأخذ POST واحدًا:

curl -X POST https://mrsaynothing.dev/letters/api/subscribe \
  -H 'content-type: application/json' \
  -d '{"email":"[email protected]"}'

قبل أن تنظر نقطة النهاية إلى بريدك، تنظر إلى حقل في النموذج اسمه website. البشر لا يرونه أبدًا فيبقى فارغًا؛ والبوتات تملأ كل شيء، فملؤه يشتري لها ok مزيفًا ولا شيء غيره. وبعد الفخ، يدخل العنوان في دلو رموز — 10 طلبات في الساعة لكل IP، وإعادة إرسال واحدة لكل عنوان كل 10 دقائق — والردود متطابقة عمدًا كان العنوان مشتركًا أم لا، حتى لا تصلح نقطة النهاية لعدّ أي أحد.

رابط التأكيد يحمل رمزًا موقّعًا بدل جلسة:

import { createHmac, timingSafeEqual } from 'node:crypto';

// payload = base64url(email|action), MAC = HMAC-SHA256(payload)
export function sign(email: string, action: 'confirm' | 'unsub'): string {
  const payload = Buffer.from(`${email}|${action}`).toString('base64url');
  const mac = createHmac('sha256', config.hmacSecret).update(payload).digest('base64url');
  return `${payload}.${mac}`;
}

التحقق هو timingSafeEqual مقابل MAC يُحسب من جديد. لا جدول للرموز ولا صلاحية تنتهي — التوقيع هو السلطة الوحيدة، فارابط التأكيد يعمل حتى والقاعدة في منتصف إعادة التشغيل. ورموز إلغاء الاشتراك لا تنتهي أبدًا، عن قصد: آلية الموافقة يجب أن تظل تعمل إلى الأبد، وإبطالها يعني تدوير مفتاح التوقيع، وهو يسكن خارج المستودع ويُوصل للقراءة فقط وقت التشغيل.

رابط تأكيد يعمل والقاعدة نائمة: شيء واحد أقل يمكن أن يتعفّن.

عُرف غريب لدى المستقبِلات هو من حدّد إعدادات الأمان: إلغاء الاشتراك بنقرة واحدة في Gmail يطلق POST عبر نطاق مختلف مباشرة إلى نقطة النهاية، فكان لا بد من تعطيل فحص نفس الأصل المعتاد على هذا المسار — MAC هو من يصادق هناك، وترويسة الأصل كانت تعيق معيارًا فقط.

ما الذي انكسر أثناء البناء؟

السجل، بترتيب الحدوث:

  1. فشل بناء الصورة بخطأ ERR_PNPM_IGNORED_BUILDS. كان pnpm install --frozen-lockfile يمر محليًا ويموت داخل الحاوية، لأن سكربت postinstall الخاص بـ esbuild لم يكن معتمدًا أصلًا. الإصلاح: تضمين ملف pnpm-workspace.yaml الصغير الذي يعلن سكربتات البناء المسموحة داخل الصورة. نصف يوم للعثور، ملف واحد للإصلاح.
  2. الحماية من CSRF حجبت Gmail. طلب Gmail ذو النقرة الواحدة لا يحمل بوضوح أي بيانات نفس الأصل. المسار يثق الآن بالرمز، والرمز لا يُزوَّر.
  3. التسليم له سقف على الطريق المجاني. بدون DKIM متوافق، يمنح mail-tester الإعداد حوالي 5/10، وقد تُرسِل الفلاتر الصارمة الرسالة. هذه هي التكلفة الصادقة لتجاوز relay مدفوع؛ والإصلاح معروف ومنتظر أن تبرره القائمة يومًا.

ما الذي لا يفعله بعد؟

لا تتبّعًا للفتح — وهذه مبدأ، لأن بكسل التتبع هو سبب انعدام ثقة الناس بالنشرات. قائمة واحدة، بلا شرائح، ولا جدولة بعد واجهة سطر أوامر تُستدعى يدويًا أو بمؤقّت، إضافة إلى سقف التسليم أعلاه. الإرسال أمر واحد: cli.mjs send --subject ... --file post.md، مع علم --dry يصيّر النص وHTML كاملين للمراجعة قبل أن يتحرك أي شيء. أما قصة النسخ الاحتياطي فهي قصة SQLite: انسخ الملف، استعد الملف، ووضع WAL يبقي النسخة متسقة والخادم يعمل. وشغله تحت إشراف systemd هو باقي شغل التشغيل.

215 سطرًا منها هي واجهة سطر الأوامر. والـ 1,043 الباقية كلها «موافقة».

أي جزء من حزبتك يديره مورد مستأجر وأنت تفضّل امتلاكه في ملف تستطيع قراءته؟ وأي اشتراك ما زلت تدفع ثمنه لأن ترحيل البيانات يبدو أسوأ من الفاتورة؟

اشترك في نشرة mrsaynothing.dev/newsletter — إنها تعمل على هذا.