ما هو هذا النظام؟
هذا النظام يسمح لك بإرسال رسائل بريد إلكتروني (إيميلات) لعدد كبير من الأشخاص في نفس الوقت. تخيل أنك تريد إرسال نفس الرسالة لـ 1000 شخص - بدلاً من إرسالها واحدة واحدة، هذا النظام يرسلها لك تلقائياً!
النظام يتكون من 3 أجزاء رئيسية:
Supabase
هذا هو "المخزن" الذي يحفظ طابور الرسائل. فكر فيه مثل مستودع يحفظ الرسائل قبل إرسالها.
SMTP
هذا هو "ساعي البريد" الذي يوصل الرسائل. تحتاج خادم بريد (مثل بريد شركتك أو Gmail) لإرسال الرسائل فعلياً.
الواجهة
هذه الصفحة التي تراها الآن - مكان كتابة الرسائل وإضافة العناوين والتحكم في الإرسال.
إعداد Supabase (الخطوة الأولى)
Supabase هي خدمة مجانية تعمل كقاعدة بيانات على الإنترنت. سنستخدمها لحفظ طابور الرسائل ومعالجتها. اتبع هذه الخطوات بدقة:
إنشاء حساب في Supabase
افتح موقع supabase.com واضغط على زر "Start your project" أو "Sign Up".
يمكنك التسجيل باستخدام حساب GitHub الخاص بك (الأسهل) أو بالبريد الإلكتروني وكلمة مرور.
إنشاء مشروع جديد
بعد تسجيل الدخول:
- اضغط على "New Project" (مشروع جديد)
- اختر اسماً للمشروع (مثلاً:
email-system) - أنشئ كلمة مرور قوية لقاعدة البيانات (احفظها في مكان آمن)
- اختر المنطقة الأقرب لك جغرافياً (مثل: Central EU لأوروبا)
- اختر الخطة المجانية "Free"
- فعّل "Enable automatic RLS" (تفعيل الحماية التلقائية للصفوف)
- اضغط "Create new project"
انتظر دقيقة أو دقيقتين حتى يتم إنشاء المشروع.
نسخ الرابط والمفتاح
بعد إنشاء المشروع، ستجد المعلومات التي تحتاجها لربط الموقع.
- من القائمة الجانبية، اضغط على Project Overview
- ستجد قسم Project URL — انسخ الرابط (يبدأ بـ
httpsوينتهي بـsupabase.co) - من القائمة الجانبية، اضغط على Project Settings (أيقونة الترس)
- اضغط على API Keys من القائمة الفرعية
- ستجد قسم Legacy anon, service_role API keys
- ستجد anon — انسخ anon public (يبدأ بـ
eyJ...)
إنشاء جداول قاعدة البيانات (Migration)
الآن نحتاج إنشاء "جدولين" في قاعدة البيانات: الأول ليحفظ طابور الرسائل، والثاني ليحفظ الحملات البريدية. فكر في كل جدول مثل جدول Excel لكن على الإنترنت.
فتح محرر SQL
في لوحة تحكم Supabase:
- من القائمة الجانبية اليسرى، اضغط على أيقونة "SQL Editor" (شكلها مثل قاعدة بيانات مع قلم)
- ستفتح لك صفحة بيضاء لكتابة أوامر SQL
نسخ ولصق كود إنشاء الجدول
انسخ الكود التالي بالكامل والصقه في محرر SQL ثم اضغط "Run" (تشغيل):
CREATE TABLE IF NOT EXISTS email_queue (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_id text NOT NULL,
client_token text NOT NULL,
recipients text[] NOT NULL DEFAULT '{}',
subject text NOT NULL,
body text,
html text,
send_type text NOT NULL DEFAULT 'bcc',
smtp_config jsonb NOT NULL DEFAULT '{}',
personalize jsonb DEFAULT '{}',
status text NOT NULL DEFAULT 'pending',
error_message text,
batch_index int NOT NULL DEFAULT 0,
total_batches int NOT NULL DEFAULT 1,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
processed_at timestamptz
);
ALTER TABLE email_queue ENABLE ROW LEVEL SECURITY;
CREATE INDEX IF NOT EXISTS idx_email_queue_job_client
ON email_queue (job_id, client_token);
CREATE INDEX IF NOT EXISTS idx_email_queue_status_created
ON email_queue (status, created_at);
التأكد من نجاح العملية
بعد الضغط على Run، يجب أن ترى رسالة "Success" باللون الأخضر. هذا يعني أن الجدول تم إنشاؤه بنجاح!
إنشاء جدول الحملات (Campaigns)
جدول الحملات يحفظ الحملات البريدية مع المستلمين والرسائل والإعدادات والنتائج. اتبع نفس الخطوات: افتح SQL Editor، والصق الكود التالي ثم اضغط Run:
CREATE TABLE IF NOT EXISTS campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_token text NOT NULL,
name text NOT NULL,
recipients text[] NOT NULL DEFAULT '{}',
messages jsonb NOT NULL DEFAULT '[]'::jsonb,
selected_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
send_type text NOT NULL DEFAULT 'bcc',
batch_size int NOT NULL DEFAULT 50,
batch_delay int NOT NULL DEFAULT 3,
smtp_config jsonb NOT NULL DEFAULT '{}'::jsonb,
smtp_mode text NOT NULL DEFAULT 'rotation',
personalize jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL DEFAULT 'draft',
results jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
last_run_at timestamptz
);
ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "anon_select_campaigns" ON campaigns;
CREATE POLICY "anon_select_campaigns" ON campaigns FOR SELECT
TO anon, authenticated USING (true);
DROP POLICY IF EXISTS "anon_insert_campaigns" ON campaigns;
CREATE POLICY "anon_insert_campaigns" ON campaigns FOR INSERT
TO anon, authenticated WITH CHECK (true);
DROP POLICY IF EXISTS "anon_update_campaigns" ON campaigns;
CREATE POLICY "anon_update_campaigns" ON campaigns FOR UPDATE
TO anon, authenticated USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "anon_delete_campaigns" ON campaigns;
CREATE POLICY "anon_delete_campaigns" ON campaigns FOR DELETE
TO anon, authenticated USING (true);
CREATE INDEX IF NOT EXISTS idx_campaigns_client_token
ON campaigns (client_token);
CREATE INDEX IF NOT EXISTS idx_campaigns_status
ON campaigns (status);
CREATE INDEX IF NOT EXISTS idx_campaigns_created
ON campaigns (created_at DESC);
بعد التشغيل، ستظهر رسالة "Success". يمكنك التأكد من وجود الجدول في Table Editor — ستجد جدول campaigns بجانب email_queue.
إنشاء جدول الإعدادات (app_settings)
جدول الإعدادات يحفظ نسخة احتياطية من كل إعدادات الموقع (Supabase، SMTP، Gemini، مضاد السبام) وكل بيانات المتصفح المحفوظة. يسمح لك باسترجاع إعداداتك على أي جهاز. اتبع نفس الخطوات: افتح SQL Editor، والصق الكود التالي ثم اضغط Run:
CREATE TABLE IF NOT EXISTS app_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_token text NOT NULL,
settings_data jsonb NOT NULL DEFAULT '{}'::jsonb,
backup_data jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
UNIQUE (client_token)
);
ALTER TABLE app_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "anon_select_app_settings" ON app_settings;
CREATE POLICY "anon_select_app_settings" ON app_settings FOR SELECT
TO anon, authenticated USING (true);
DROP POLICY IF EXISTS "anon_insert_app_settings" ON app_settings;
CREATE POLICY "anon_insert_app_settings" ON app_settings FOR INSERT
TO anon, authenticated WITH CHECK (true);
DROP POLICY IF EXISTS "anon_update_app_settings" ON app_settings;
CREATE POLICY "anon_update_app_settings" ON app_settings FOR UPDATE
TO anon, authenticated USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "anon_delete_app_settings" ON app_settings;
CREATE POLICY "anon_delete_app_settings" ON app_settings FOR DELETE
TO anon, authenticated USING (true);
بعد التشغيل، ستظهر رسالة "Success". ستجد جدول app_settings في Table Editor بجانب الجداول الأخرى.
إضافة Edge Functions (الوظائف)
الـ Edge Functions هي "برامج صغيرة" تعمل على خوادم Supabase. نحتاج ثلاث وظائف: واحدة لإرسال البريد مباشرة، والثانية لإدارة طابور الإرسال، والثالثة لإدارة الحملات البريدية.
الخطوة الأولى: إضافة وظيفة email-queue
فتح محرر الوظائف
من القائمة الجانبية في Supabase، اختر قسم Edge Functions. ضمن خيار Deploy a new function، اضغط على زر Via Editor.
إدخال اسم الوظيفة
في الأسفل، ستجد خانة Function name؛ أضف هذا الاسم:
email-queue
نسخ كود الوظيفة
قم باستبدال الكود الموجود بالكامل بالكود التالي (انسخه كاملاً):
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
import nodemailer from "npm:nodemailer@6";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-Client-Info, Apikey",
};
function getSupabaseAdmin() {
return createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
}
function getRecipientName(email: string): string {
return email.split("@")[0];
}
function personalizeSubjectText(subject: string, email: string): string {
return subject + " -" + getRecipientName(email) + "-";
}
function personalizeBodyText(body: string, email: string, prefix: string): string {
return prefix + " " + email + "\n\n" + body;
}
function personalizeHtmlText(html: string, email: string, prefix: string): string {
const greeting = '<p style="margin:0 0 12px 0;">' + prefix + " " + email + "</p>";
if (html.toLowerCase().includes("<body")) {
return html.replace(/(<body[^>]*>)/i, "$1" + greeting);
}
return greeting + html;
}
function addUnsubscribeLink(html: string | undefined, text: string | undefined, url: string): { html?: string; text?: string } {
const result: { html?: string; text?: string } = {};
if (html) {
const unsubHtml = '<div style="margin-top:20px;padding-top:12px;border-top:1px solid #ddd;font-size:12px;color:#888;text-align:center;"><a href="' + url + '" style="color:#888;text-decoration:underline;">Unsubscribe</a></div>';
if (html.toLowerCase().includes("</body>")) {
result.html = html.replace(/(<\/body>)/i, unsubHtml + "$1");
} else {
result.html = html + unsubHtml;
}
}
if (text) {
result.text = text + "\n\n---\nUnsubscribe: " + url;
}
return result;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildAntispamHeaders(unsubscribeUrl?: string): Record<string, string> {
const headers: Record<string, string> = {
"Precedence": "bulk",
"X-Auto-Response-Suppress": "All",
"Auto-Submitted": "auto-generated",
"X-Priority": "3",
};
if (unsubscribeUrl) {
headers["List-Unsubscribe"] = "<" + unsubscribeUrl + ">";
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return headers;
}
interface SmtpConfig {
host: string;
port: string | number;
user: string;
pass: string;
senderName?: string;
senderEmail?: string;
}
interface PersonalizeConfig {
subject?: boolean;
body?: boolean;
greetingPrefix?: string;
randomDelay?: boolean;
unsubscribe?: boolean;
unsubscribeUrl?: string;
}
interface EnqueuePayload {
recipients: string[];
subject: string;
body?: string;
html?: string;
sendType?: string;
smtpConfig: SmtpConfig;
clientToken: string;
batchSize?: number;
personalize?: PersonalizeConfig;
}
interface ProcessPayload {
jobId: string;
clientToken: string;
}
interface StatusPayload {
jobId: string;
clientToken: string;
}
interface CancelPayload {
jobId: string;
clientToken: string;
}
async function handleEnqueue(data: EnqueuePayload) {
const {
recipients,
subject,
body,
html,
sendType,
smtpConfig,
clientToken,
batchSize = 50,
personalize,
} = data;
if (
!smtpConfig ||
!smtpConfig.host ||
!smtpConfig.port ||
!smtpConfig.user ||
!smtpConfig.pass
) {
return jsonResponse(
{ success: false, error: "SMTP settings missing" },
400
);
}
if (!recipients || !recipients.length || !subject) {
return jsonResponse(
{ success: false, error: "Missing recipients or subject" },
400
);
}
if (!clientToken) {
return jsonResponse(
{ success: false, error: "Missing client token" },
400
);
}
const supabase = getSupabaseAdmin();
const jobId = crypto.randomUUID();
const batches: string[][] = [];
for (let i = 0; i < recipients.length; i += batchSize) {
batches.push(recipients.slice(i, i + batchSize));
}
const rows = batches.map((batch, index) => ({
job_id: jobId,
client_token: clientToken,
recipients: batch,
subject,
body: body || null,
html: html || null,
send_type: sendType || "bcc",
smtp_config: smtpConfig,
personalize: personalize || {},
status: "pending",
batch_index: index,
total_batches: batches.length,
}));
const { error } = await supabase.from("email_queue").insert(rows);
if (error) {
return jsonResponse({ success: false, error: error.message }, 500);
}
return jsonResponse({
success: true,
jobId,
totalBatches: batches.length,
totalRecipients: recipients.length,
batchSize,
});
}
async function handleProcess(data: ProcessPayload) {
const { jobId, clientToken } = data;
if (!jobId || !clientToken) {
return jsonResponse(
{ success: false, error: "Missing jobId or clientToken" },
400
);
}
const supabase = getSupabaseAdmin();
const { data: batch, error: fetchError } = await supabase
.from("email_queue")
.select("*")
.eq("job_id", jobId)
.eq("client_token", clientToken)
.eq("status", "pending")
.order("batch_index", { ascending: true })
.limit(1)
.maybeSingle();
if (fetchError) {
return jsonResponse({ success: false, error: fetchError.message }, 500);
}
if (!batch) {
const { data: allBatches } = await supabase
.from("email_queue")
.select("status")
.eq("job_id", jobId)
.eq("client_token", clientToken);
const completed =
allBatches?.filter((b) => b.status === "completed").length || 0;
const failed =
allBatches?.filter((b) => b.status === "failed").length || 0;
const total = allBatches?.length || 0;
await supabase
.from("email_queue")
.update({ smtp_config: {} })
.eq("job_id", jobId)
.eq("client_token", clientToken)
.in("status", ["completed", "failed"]);
return jsonResponse({
success: true,
done: true,
completed,
failed,
total,
});
}
await supabase
.from("email_queue")
.update({ status: "processing", updated_at: new Date().toISOString() })
.eq("id", batch.id);
try {
const smtp: SmtpConfig = batch.smtp_config;
const transporter = nodemailer.createTransport({
host: smtp.host,
port: Number(smtp.port),
secure: Number(smtp.port) === 465,
auth: { user: smtp.user, pass: smtp.pass },
});
const senderName = smtp.senderName || smtp.user;
const senderEmail = smtp.senderEmail || smtp.user;
const sender = `"${senderName}" <${senderEmail}>`;
const p: PersonalizeConfig | undefined = batch.personalize && Object.keys(batch.personalize).length > 0 ? batch.personalize : undefined;
const needsIndividual = p && (p.subject || p.body || p.unsubscribe);
if (needsIndividual) {
let sentCount = 0;
const errors: string[] = [];
for (let i = 0; i < batch.recipients.length; i++) {
const email = batch.recipients[i];
let mailSubject = batch.subject;
let mailText = batch.body;
let mailHtml = batch.html;
if (p.subject) {
mailSubject = personalizeSubjectText(mailSubject, email);
}
if (p.body && p.greetingPrefix) {
if (mailHtml) {
mailHtml = personalizeHtmlText(mailHtml, email, p.greetingPrefix);
}
if (mailText) {
mailText = personalizeBodyText(mailText, email, p.greetingPrefix);
}
}
if (p.unsubscribe && p.unsubscribeUrl) {
const unsub = addUnsubscribeLink(mailHtml, mailText, p.unsubscribeUrl);
if (unsub.html) mailHtml = unsub.html;
if (unsub.text) mailText = unsub.text;
}
const unsubUrl = p.unsubscribe && p.unsubscribeUrl ? p.unsubscribeUrl : undefined;
const mailOptions: Record<string, unknown> = {
from: sender,
to: email,
subject: mailSubject,
replyTo: senderEmail,
headers: buildAntispamHeaders(unsubUrl),
};
if (mailHtml) {
mailOptions.html = mailHtml;
mailOptions.text = mailText || mailHtml.replace(/<[^>]*>/g, "").trim();
} else {
mailOptions.text = mailText;
}
try {
await transporter.sendMail(mailOptions);
sentCount++;
} catch (err) {
errors.push(email + ": " + (err as Error).message);
}
if (p.randomDelay && i < batch.recipients.length - 1) {
await sleep(1000 + Math.random() * 2000);
}
}
await supabase
.from("email_queue")
.update({
status: errors.length === 0 ? "completed" : (sentCount > 0 ? "completed" : "failed"),
error_message: errors.length > 0 ? errors.join("; ") : null,
smtp_config: {},
personalize: {},
processed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", batch.id);
return jsonResponse({
success: true,
done: false,
batchIndex: batch.batch_index,
totalBatches: batch.total_batches,
sentCount: sentCount,
personalized: true,
});
} else {
const mailOptions: Record<string, unknown> = {
from: sender,
subject: batch.subject,
replyTo: senderEmail,
headers: buildAntispamHeaders(),
};
if (batch.html) {
mailOptions.html = batch.html;
mailOptions.text = batch.body || batch.html.replace(/<[^>]*>/g, "").trim();
} else {
mailOptions.text = batch.body;
}
if (batch.send_type === "bcc") {
mailOptions.bcc = batch.recipients.join(", ");
} else {
mailOptions.to = batch.recipients.join(", ");
}
await transporter.sendMail(mailOptions);
await supabase
.from("email_queue")
.update({
status: "completed",
smtp_config: {},
personalize: {},
processed_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.eq("id", batch.id);
return jsonResponse({
success: true,
done: false,
batchIndex: batch.batch_index,
totalBatches: batch.total_batches,
sentCount: batch.recipients.length,
});
}
} catch (error) {
await supabase
.from("email_queue")
.update({
status: "failed",
error_message: (error as Error).message,
smtp_config: {},
personalize: {},
updated_at: new Date().toISOString(),
})
.eq("id", batch.id);
return jsonResponse({
success: false,
done: false,
batchIndex: batch.batch_index,
error: (error as Error).message,
});
}
}
async function handleStatus(data: StatusPayload) {
const { jobId, clientToken } = data;
if (!jobId || !clientToken) {
return jsonResponse(
{ success: false, error: "Missing jobId or clientToken" },
400
);
}
const supabase = getSupabaseAdmin();
const { data: batches, error } = await supabase
.from("email_queue")
.select(
"status, batch_index, total_batches, recipients, error_message, processed_at"
)
.eq("job_id", jobId)
.eq("client_token", clientToken)
.order("batch_index", { ascending: true });
if (error) {
return jsonResponse({ success: false, error: error.message }, 500);
}
if (!batches || batches.length === 0) {
return jsonResponse({ success: false, error: "Job not found" }, 404);
}
const total = batches.length;
const completed = batches.filter((b) => b.status === "completed").length;
const failed = batches.filter((b) => b.status === "failed").length;
const pending = batches.filter((b) => b.status === "pending").length;
const processing = batches.filter((b) => b.status === "processing").length;
const totalRecipients = batches.reduce(
(sum, b) => sum + (b.recipients?.length || 0),
0
);
const sentRecipients = batches
.filter((b) => b.status === "completed")
.reduce((sum, b) => sum + (b.recipients?.length || 0), 0);
const errors = batches
.filter((b) => b.status === "failed" && b.error_message)
.map((b) => ({ batch: b.batch_index, error: b.error_message }));
return jsonResponse({
success: true,
jobId,
totalBatches: total,
completed,
failed,
pending,
processing,
totalRecipients,
sentRecipients,
isDone: pending === 0 && processing === 0,
errors,
});
}
async function handleCancel(data: CancelPayload) {
const { jobId, clientToken } = data;
if (!jobId || !clientToken) {
return jsonResponse(
{ success: false, error: "Missing jobId or clientToken" },
400
);
}
const supabase = getSupabaseAdmin();
const { error } = await supabase
.from("email_queue")
.update({
status: "cancelled",
smtp_config: {},
updated_at: new Date().toISOString(),
})
.eq("job_id", jobId)
.eq("client_token", clientToken)
.eq("status", "pending");
if (error) {
return jsonResponse({ success: false, error: error.message }, 500);
}
await supabase
.from("email_queue")
.update({ smtp_config: {} })
.eq("job_id", jobId)
.eq("client_token", clientToken);
return jsonResponse({ success: true });
}
function jsonResponse(data: Record<string, unknown>, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 200, headers: corsHeaders });
}
try {
const { action, ...data } = await req.json();
switch (action) {
case "enqueue":
return await handleEnqueue(data as unknown as EnqueuePayload);
case "process":
return await handleProcess(data as unknown as ProcessPayload);
case "status":
return await handleStatus(data as unknown as StatusPayload);
case "cancel":
return await handleCancel(data as unknown as CancelPayload);
default:
return jsonResponse({ success: false, error: "Unknown action" }, 400);
}
} catch (error) {
return jsonResponse(
{ success: false, error: (error as Error).message },
500
);
}
});
نشر الوظيفة
اضغط على زر "Deploy function" لنشر الوظيفة. انتظر حتى تظهر رسالة النجاح.
الخطوة الثانية: إضافة وظيفة send-email
إنشاء وظيفة جديدة
من نفس صفحة Edge Functions، اضغط مرة أخرى على Deploy a new function ثم Via Editor.
إدخال اسم الوظيفة
في خانة Function name، أضف هذا الاسم:
send-email
نسخ كود الوظيفة
قم باستبدال الكود الموجود بالكامل بالكود التالي (انسخه كاملاً):
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import nodemailer from "npm:nodemailer@6";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers":
"Content-Type, Authorization, X-Client-Info, Apikey",
};
const MIN_INTERVAL_MS = 60 * 1000;
const lastSentMap = new Map<string, number>();
function getRecipientName(email: string): string {
return email.split("@")[0];
}
function personalizeSubjectText(subject: string, email: string): string {
return subject + " -" + getRecipientName(email) + "-";
}
function personalizeBodyText(body: string, email: string, prefix: string): string {
return prefix + " " + email + "\n\n" + body;
}
function personalizeHtmlText(html: string, email: string, prefix: string): string {
const greeting = '<p style="margin:0 0 12px 0;">' + prefix + " " + email + "</p>";
if (html.toLowerCase().includes("<body")) {
return html.replace(/(<body[^>]*>)/i, "$1" + greeting);
}
return greeting + html;
}
function addUnsubscribeLink(html: string | undefined, text: string | undefined, url: string): { html?: string; text?: string } {
const result: { html?: string; text?: string } = {};
if (html) {
const unsubHtml = '<div style="margin-top:20px;padding-top:12px;border-top:1px solid #ddd;font-size:12px;color:#888;text-align:center;"><a href="' + url + '" style="color:#888;text-decoration:underline;">Unsubscribe</a></div>';
if (html.toLowerCase().includes("</body>")) {
result.html = html.replace(/(<\/body>)/i, unsubHtml + "$1");
} else {
result.html = html + unsubHtml;
}
}
if (text) {
result.text = text + "\n\n---\nUnsubscribe: " + url;
}
return result;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildAntispamHeaders(unsubscribeUrl?: string): Record<string, string> {
const headers: Record<string, string> = {
"Precedence": "bulk",
"X-Auto-Response-Suppress": "All",
"Auto-Submitted": "auto-generated",
"X-Priority": "3",
};
if (unsubscribeUrl) {
headers["List-Unsubscribe"] = "<" + unsubscribeUrl + ">";
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
}
return headers;
}
interface SmtpConfig {
host: string;
port: string | number;
user: string;
pass: string;
senderName?: string;
senderEmail?: string;
}
interface PersonalizeConfig {
subject?: boolean;
body?: boolean;
greetingPrefix?: string;
randomDelay?: boolean;
unsubscribe?: boolean;
unsubscribeUrl?: string;
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 200, headers: corsHeaders });
}
try {
const { to, subject, body, html, sendType, smtpConfig, personalize } = await req.json();
if (!smtpConfig || !smtpConfig.host || !smtpConfig.port || !smtpConfig.user || !smtpConfig.pass) {
return new Response(
JSON.stringify({ success: false, error: "SMTP settings are missing. Please configure your SMTP settings first." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!to || !subject || (!body && !html)) {
return new Response(
JSON.stringify({ success: false, error: "Missing required fields: to, subject, body or html" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const recipients = Array.isArray(to) ? to : [to];
const now = Date.now();
const rateLimited: string[] = [];
const allowed: string[] = [];
for (const email of recipients) {
const lastSent = lastSentMap.get(email);
if (lastSent && now - lastSent < MIN_INTERVAL_MS) {
rateLimited.push(email);
} else {
allowed.push(email);
}
}
if (allowed.length === 0) {
return new Response(
JSON.stringify({
success: false,
error: "All recipients are rate-limited. Please wait 60 seconds.",
rateLimited,
}),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const transportConfig = {
host: smtpConfig.host,
port: Number(smtpConfig.port),
secure: Number(smtpConfig.port) === 465,
auth: {
user: smtpConfig.user,
pass: smtpConfig.pass,
},
};
const senderName = smtpConfig.senderName || smtpConfig.user;
const senderEmail = smtpConfig.senderEmail || smtpConfig.user;
const sender = `"${senderName}" <${senderEmail}>`;
const transporter = nodemailer.createTransport(transportConfig);
const p: PersonalizeConfig | undefined = personalize && Object.keys(personalize).length > 0 ? personalize : undefined;
const needsIndividual = p && (p.subject || p.body || p.unsubscribe);
if (needsIndividual) {
let sentCount = 0;
const errors: string[] = [];
for (let i = 0; i < allowed.length; i++) {
const email = allowed[i];
let mailSubject = subject;
let mailText = body;
let mailHtml = html;
if (p.subject) {
mailSubject = personalizeSubjectText(mailSubject, email);
}
if (p.body && p.greetingPrefix) {
if (mailHtml) {
mailHtml = personalizeHtmlText(mailHtml, email, p.greetingPrefix);
}
if (mailText) {
mailText = personalizeBodyText(mailText, email, p.greetingPrefix);
}
}
if (p.unsubscribe && p.unsubscribeUrl) {
const unsub = addUnsubscribeLink(mailHtml, mailText, p.unsubscribeUrl);
if (unsub.html) mailHtml = unsub.html;
if (unsub.text) mailText = unsub.text;
}
const unsubUrl = p.unsubscribe && p.unsubscribeUrl ? p.unsubscribeUrl : undefined;
const mailOptions: Record<string, unknown> = {
from: sender,
to: email,
subject: mailSubject,
replyTo: senderEmail,
headers: buildAntispamHeaders(unsubUrl),
};
if (mailHtml) {
mailOptions.html = mailHtml;
mailOptions.text = mailText || mailHtml.replace(/<[^>]*>/g, "").trim();
} else {
mailOptions.text = mailText;
}
try {
await transporter.sendMail(mailOptions);
sentCount++;
lastSentMap.set(email, Date.now());
} catch (err) {
errors.push(email + ": " + (err as Error).message);
}
if (p.randomDelay && i < allowed.length - 1) {
await sleep(1000 + Math.random() * 2000);
}
}
return new Response(
JSON.stringify({
success: sentCount > 0,
sent: sentCount,
rateLimited: rateLimited.length,
rateLimitedEmails: rateLimited,
personalized: true,
errors: errors.length > 0 ? errors : undefined,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const mailOptions: Record<string, unknown> = {
from: sender,
subject: subject,
replyTo: senderEmail,
headers: buildAntispamHeaders(),
};
if (html) {
mailOptions.html = html;
mailOptions.text = body || html.replace(/<[^>]*>/g, "").trim();
} else {
mailOptions.text = body;
}
if (sendType === "bcc") {
mailOptions.bcc = allowed.join(", ");
} else {
mailOptions.to = allowed.join(", ");
}
const info = await transporter.sendMail(mailOptions);
for (const email of allowed) {
lastSentMap.set(email, now);
}
return new Response(
JSON.stringify({
success: true,
messageId: info.messageId,
sent: allowed.length,
rateLimited: rateLimited.length,
rateLimitedEmails: rateLimited,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ success: false, error: (error as Error).message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
نشر الوظيفة
اضغط على زر "Deploy function" لنشر الوظيفة. انتظر حتى تظهر رسالة النجاح.
الخطوة الثالثة: إضافة وظيفة campaigns
وظيفة campaigns تدير عمليات إنشاء وتعديل وحذف وتشغيل الحملات البريدية. بدونها لن يعمل تبويب الحملات.
إنشاء وظيفة جديدة
من نفس صفحة Edge Functions، اضغط مرة أخرى على Deploy a new function ثم Via Editor.
إدخال اسم الوظيفة
في خانة Function name، أضف هذا الاسم:
campaigns
نسخ كود الوظيفة
قم باستبدال الكود الموجود بالكامل بالكود التالي (انسخه كاملاً):
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Client-Info, Apikey",
};
function getSupabaseAdmin() {
return createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
}
interface Campaign {
id?: string;
client_token: string;
name: string;
recipients?: string[];
messages?: unknown[];
selected_message_ids?: unknown[];
send_type?: string;
batch_size?: number;
batch_delay?: number;
smtp_config?: Record<string, unknown>;
smtp_mode?: string;
personalize?: Record<string, unknown>;
status?: string;
results?: Record<string, unknown>;
}
function jsonResponse(data: Record<string, unknown>, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
async function handleList(clientToken: string) {
if (!clientToken) return jsonResponse({ success: false, error: "Missing client token" }, 400);
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from("campaigns")
.select("*")
.eq("client_token", clientToken)
.order("created_at", { ascending: false });
if (error) return jsonResponse({ success: false, error: error.message }, 500);
return jsonResponse({ success: true, campaigns: data || [] });
}
async function handleGet(clientToken: string, campaignId: string) {
if (!clientToken || !campaignId)
return jsonResponse({ success: false, error: "Missing client token or campaign id" }, 400);
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from("campaigns")
.select("*")
.eq("id", campaignId)
.eq("client_token", clientToken)
.maybeSingle();
if (error) return jsonResponse({ success: false, error: error.message }, 500);
if (!data) return jsonResponse({ success: false, error: "Campaign not found" }, 404);
return jsonResponse({ success: true, campaign: data });
}
async function handleCreate(campaign: Campaign) {
if (!campaign.client_token) return jsonResponse({ success: false, error: "Missing client token" }, 400);
if (!campaign.name) return jsonResponse({ success: false, error: "Missing campaign name" }, 400);
const supabase = getSupabaseAdmin();
const insertData: Record<string, unknown> = {
client_token: campaign.client_token,
name: campaign.name,
recipients: campaign.recipients || [],
messages: campaign.messages || [],
selected_message_ids: campaign.selected_message_ids || [],
send_type: campaign.send_type || "bcc",
batch_size: campaign.batch_size || 50,
batch_delay: campaign.batch_delay || 3,
smtp_config: campaign.smtp_config || {},
smtp_mode: campaign.smtp_mode || "rotation",
personalize: campaign.personalize || {},
status: campaign.status || "draft",
results: campaign.results || {},
};
const { data, error } = await supabase
.from("campaigns")
.insert(insertData)
.select()
.single();
if (error) return jsonResponse({ success: false, error: error.message }, 500);
return jsonResponse({ success: true, campaign: data });
}
async function handleUpdate(clientToken: string, campaignId: string, updates: Record<string, unknown>) {
if (!clientToken || !campaignId)
return jsonResponse({ success: false, error: "Missing client token or campaign id" }, 400);
const allowed = [
"name", "recipients", "messages", "selected_message_ids", "send_type",
"batch_size", "batch_delay", "smtp_config", "smtp_mode", "personalize",
"status", "results",
];
const updateData: Record<string, unknown> = { updated_at: new Date().toISOString() };
for (const key of allowed) {
if (key in updates) updateData[key] = updates[key];
}
const supabase = getSupabaseAdmin();
const { data, error } = await supabase
.from("campaigns")
.update(updateData)
.eq("id", campaignId)
.eq("client_token", clientToken)
.select()
.maybeSingle();
if (error) return jsonResponse({ success: false, error: error.message }, 500);
if (!data) return jsonResponse({ success: false, error: "Campaign not found" }, 404);
return jsonResponse({ success: true, campaign: data });
}
async function handleDelete(clientToken: string, campaignId: string) {
if (!clientToken || !campaignId)
return jsonResponse({ success: false, error: "Missing client token or campaign id" }, 400);
const supabase = getSupabaseAdmin();
const { error } = await supabase
.from("campaigns")
.delete()
.eq("id", campaignId)
.eq("client_token", clientToken);
if (error) return jsonResponse({ success: false, error: error.message }, 500);
return jsonResponse({ success: true });
}
Deno.serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { status: 200, headers: corsHeaders });
}
try {
const body = await req.json();
const { action } = body;
switch (action) {
case "list": return await handleList(body.client_token);
case "get": return await handleGet(body.client_token, body.campaign_id);
case "create": return await handleCreate(body as Campaign);
case "update": return await handleUpdate(body.client_token, body.campaign_id, body.updates || {});
case "delete": return await handleDelete(body.client_token, body.campaign_id);
default: return jsonResponse({ success: false, error: "Unknown action" }, 400);
}
} catch (error) {
return jsonResponse({ success: false, error: (error as Error).message }, 500);
}
});
نشر الوظيفة
اضغط على زر "Deploy function" لنشر الوظيفة. انتظر حتى تظهر رسالة النجاح.
التحقق من النشر
التأكد من وجود الوظائف الثلاث
في لوحة تحكم Supabase:
- اذهب إلى "Edge Functions" من القائمة الجانبية
- يجب أن ترى ثلاث وظائف: send-email و email-queue و campaigns
https://xxxxxxx.supabase.co/functions/v1/email-queue
https://xxxxxxx.supabase.co/functions/v1/send-email
https://xxxxxxx.supabase.co/functions/v1/campaigns
ستظهر لك الروابط كما هو موضح أعلاه.
إعدادات Supabase في النظام
بعد إنشاء حساب Supabase وإضافة الجدول والوظائف، ارجع للنظام الرئيسي وأدخل بياناتك:
فتح لوحة إعدادات Supabase
في الصفحة الرئيسية للنظام، ستجد في الأعلى لوحة "إعدادات Supabase". اضغط عليها لفتحها.
إدخال رابط Supabase URL
في خانة "رابط Supabase (URL)"، الصق الرابط الذي نسخته من Supabase.
مثال: https://abcdefgh.supabase.co
إدخال مفتاح Anon Key
في خانة "مفتاح Anon Key"، الصق المفتاح العام الذي نسخته.
المفتاح يبدأ عادة بـ eyJhbGciOiJIUzI1NiIs...
حفظ الإعدادات
اضغط على زر "حفظ الإعدادات". ستلاحظ أن:
- الشارة تتحول إلى "مهيأ" باللون الأخضر
- الروابط التلقائية تظهر (رابط إرسال البريد ورابط الطابور)
إعدادات SMTP (خادم البريد)
SMTP هو البروتوكول المستخدم لإرسال البريد الإلكتروني. تحتاج بيانات خادم البريد الخاص بك لإرسال الرسائل.
من أين أحصل على بيانات SMTP؟
بريد cPanel (الأكثر شيوعاً)
- Host: mail.yourdomain.com
- Port: 465 أو 587
- User: your@yourdomain.com
- Pass: كلمة مرور البريد
Gmail
- Host: smtp.gmail.com
- Port: 465
- User: your@gmail.com
- Pass: كلمة مرور التطبيق*
Outlook / Hotmail
- Host: smtp.office365.com
- Port: 587
- User: your@outlook.com
- Pass: كلمة مرور الحساب
فتح لوحة إعدادات SMTP
تحت لوحة Supabase، ستجد لوحة "إعدادات SMTP". اضغط عليها لفتحها.
ملء الحقول
- خادم SMTP (Host): عنوان خادم البريد (مثل:
mail.example.com) - المنفذ (Port): عادة
465(اتصال آمن SSL) أو587(TLS) - اسم المستخدم: بريدك الإلكتروني الكامل
- كلمة المرور: كلمة مرور البريد
- اسم المرسل: الاسم الذي سيظهر للمستلم (مثل: "شركة حميز")
- بريد المرسل: البريد الذي سيظهر كمرسل
حفظ واختبار
- اضغط "حفظ الإعدادات" لحفظها
- اضغط "اختبار الاتصال" للتأكد من صحة البيانات
- إذا ظهرت رسالة نجاح، فالإعدادات صحيحة!
هام جداً لمستخدمي Gmail
لا تستخدم كلمة مرور حساب Google العادية. يجب إنشاء "كلمة مرور التطبيقات":
- اذهب إلى إعدادات حساب Google ثم الأمان (Security).
- تأكد من تفعيل التحقق بخطوتين (2-Step Verification).
- رابط مباشر: https://myaccount.google.com/apppasswords
- ابحث عن خيار "كلمات مرور التطبيقات" (App Passwords) وأنشئ واحدة جديدة باسم "SMTP"..
- انسخ الرمز المكون من 16 حرفاً واستخدمه في حقل "كلمة المرور" أدناه.
ملء بيانات Gmail SMTP
قم بتعبئة الحقول بالبيانات التالية بدقة:
- Host (الخادم):
smtp.gmail.com - Port (المنفذ):
465(SSL) - Email (اسم المستخدم): بريدك الكامل (example@gmail.com)
- Password (كلمة المرور): ضع "كلمة مرور التطبيقات" (الـ 16 حرفاً) وليس كلمة مرورك العادية.
- اسم المرسل: اسم متجرك أو التطبيق.
- بريد المرسل: نفس بريدك الإلكتروني المستخدم أعلاه.
إدارة قائمة البريد الإلكتروني
إضافة العناوين يدوياً
اكتب عناوين البريد في مربع النص الكبير، كل عنوان في سطر جديد. مثلاً:
ahmed@example.com
sara@example.com
omar@example.com
استيراد من Excel
اضغط زر "استيراد من Excel" واختر ملف Excel أو CSV يحتوي على عناوين البريد. النظام سيستخرج العناوين تلقائياً.
حذف المكررات
اضغط "حذف المكررات" لإزالة العناوين المتكررة تلقائياً. مثلاً إذا كان ahmed@example.com مكتوباً مرتين، سيبقى مرة واحدة فقط.
عدد الإيميلات في كل دفعة
حدد كم عنوان يتم إرساله في كل مرة. مثلاً إذا وضعت 50، سيتم إرسال الرسالة لـ 50 شخص في كل دفعة.
كتابة الرسائل
إضافة رسالة جديدة
اضغط زر "إضافة رسالة جديدة" لإضافة صندوق رسالة. يمكنك إضافة عدة رسائل مختلفة.
كتابة الموضوع والمحتوى
- العنوان (الموضوع): عنوان الرسالة الذي سيظهر في صندوق البريد
- محتوى الرسالة: يمكنك كتابة نص عادي أو HTML لتصميم رسالة جميلة
تحديد الرسائل للإرسال
كل رسالة لها مربع تحديد (Checkbox). حدد الرسائل التي تريد إرسالها. يمكنك:
- "تحديد الكل" - لتحديد جميع الرسائل دفعة واحدة
- "إلغاء تحديد الكل" - لإلغاء تحديد جميع الرسائل
- البحث في الرسائل - للبحث عن رسالة معينة بعنوانها
طريقة الإرسال: BCC مقابل To
BCC (مخفي) - الافتراضي
المستلمون لا يرون عناوين بعضهم البعض. هذا هو الخيار الأفضل للرسائل الجماعية لأنه يحمي خصوصية المستلمين.
To (ظاهر)
جميع المستلمين يرون عناوين بعضهم. استخدمه فقط إذا كنت تريد أن يعرف المستلمون من هم الآخرون.
طرق الإرسال
إرسال عبر SMTP
الطريقة المباشرة - يرسل كل دفعة مباشرة عبر خادم SMTP. مناسب للأعداد الصغيرة والمتوسطة. إذا أغلقت الصفحة يتوقف الإرسال.
إرسال عبر الطابور
الطريقة الذكية - يحفظ الرسائل في قاعدة البيانات ويرسلها تدريجياً. يمكنك إيقافه مؤقتاً واستئنافه. مناسب للأعداد الكبيرة.
إرسال عبر mailto
يفتح برنامج البريد الافتراضي في جهازك (مثل Outlook أو gmail) مع تعبئة البيانات تلقائياً. مفيد للإرسال اليدوي.
أزرار إدارية
- حذف المرسل: يزيل العناوين التي تم إرسال الرسائل إليها بنجاح من القائمة
- إعادة من البداية: يعيد تعيين حالة جميع العناوين لتبدأ الإرسال من جديد
- تراجع: يتراجع عن آخر عملية قمت بها
نظام الطابور (Queue System)
نظام الطابور يعمل بهذه الطريقة:
تقسيم إلى دفعات
عند الضغط على "إرسال عبر الطابور"، يتم تقسيم جميع العناوين إلى دفعات حسب العدد المحدد (مثلاً: 50 عنوان في كل دفعة).
حفظ في قاعدة البيانات
كل دفعة تُحفظ في جدول email_queue في Supabase مع حالة "pending" (في الانتظار).
معالجة تدريجية
النظام يأخذ دفعة واحدة في كل مرة، يرسلها، ثم ينتظر (حسب التأخير المحدد) قبل الدفعة التالية.
التحكم أثناء الإرسال
- إيقاف مؤقت: يوقف الإرسال مؤقتاً (يمكن استئنافه لاحقاً)
- استئناف: يكمل الإرسال من حيث توقف
- إلغاء: يلغي جميع الدفعات المتبقية نهائياً
نظام الحملات
نظام الحملات يتيح لك إنشاء وإدارة حملات بريدية متكاملة — تجمع قائمة مستلمين، رسائل متعددة، إعدادات دفعات، ونتائج التشغيل في مكان واحد. يمكنك تشغيل الحملة، إيقافها مؤقتاً، إعادة تشغيلها، أو تكرارها بضغطة زر.
إنشاء حملة
من تبويب "الحملات"، اضغط "حملة جديدة". أدخل اسم الحملة، قائمة المستلمين (إيميل واحد لكل سطر)، حجم الدفعة، التأخير بين الدفعات، ونوع الإرسال (BCC أو To).
إضافة رسائل
يمكنك إضافة رسالة واحدة أو أكثر لكل حملة. كل رسالة لها عنوان ونص، ويمكن تفعيل وضع HTML لكتابة رسائل منسقة. عند تشغيل الحملة، يتم تداول الرسائل بالتسلسل بين الدفعات.
تشغيل الحملة
اضغط زر التشغيل على بطاقة الحملة. سيبدأ النظام بإرسال الدفعات بالتسلسل، مع عرض التقدم المباشر في لوحة الطابور. يمكنك الإيقاف المؤقت أو الإلغاء في أي وقت.
تتبع النتائج
بعد انتهاء الحملة، تعرض البطاقة عدد الإيميلات المرسلة ونسبة التقدم. حالة الحملة تتحدث تلقائياً: مسودة، جاهزة، قيد التشغيل، متوقفة، مكتملة، أو فاشلة.
تكرار الحملة
اضغط زر النسخ لإنشاء نسخة من الحملة بنفس الإعدادات والرسائل. مفيد لإعادة إرسال حملة مشابهة مع تعديلات بسيطة.
إعادة التشغيل
الحملات المكتملة يمكن إعادة تشغيلها مرة أخرى بنفس الإعدادات. النتائج السابقة تُحدّث بالقيم الجديدة.
لوحة تفاصيل الحملة
اضغط زر العرض على بطاقة الحملة لفتح لوحة تفصيلية تعرض: معلومات الحملة (الاسم، تاريخ الإنشاء، آخر إرسال، الحالة)، إحصائيات مرئية (إجمالي المستلمين، الرسائل المرسلة، الدفعات الناجحة والفاشلة)، شريط تقدم الإرسال، رسمين بيانيين (دائري للأهداف وأعمدة للدفعات)، وقائمة بجميع رسائل الحملة.
تصدير مستلمي الحملة
من لوحة تفاصيل الحملة، يمكنك تصدير قائمة المستلمين إلى ملف CSV بثلاث خيارات: تصدير جميع المستلمين، تصدير الناجحين فقط، أو تصدير الفاشلين فقط. مفيد للمتابعة وإعادة المحاولة.
تدوير حسابات SMTP
في إعدادات الحملة، يمكنك اختيار وضع "تدوير" (Rotation) ليوزع النظام الدفعات على عدة حسابات SMTP بالتساوي، أو اختيار حساب محدد لاستخدامه فقط. التدوير يقلل احتمال حظر خادم البريد عند الإرسال بأعداد كبيرة.
نظام القوالب
القوالب تسمح لك بحفظ رسائلك وإعداداتك لاستخدامها مرة أخرى لاحقاً بدون إعادة كتابتها.
حفظ القالب
اكتب اسماً للقالب ثم اضغط "حفظ". سيتم حفظ جميع الرسائل والعناوين والإعدادات الحالية.
تحميل القالب
اختر قالباً محفوظاً من القائمة المنسدلة وسيتم تعبئة جميع الحقول تلقائياً.
تصدير القوالب
اضغط "تصدير" لتحميل ملف JSON يحتوي على جميع قوالبك. مفيد للنسخ الاحتياطي أو المشاركة.
استيراد القوالب
اضغط "استيراد" واختر ملف JSON يحتوي على قوالب لاستعادتها.
التصدير
توليد روابط الإرسال
ينشئ روابط mailto: جاهزة يمكن مشاركتها أو فتحها مباشرة في برنامج البريد.
تحميل ملف نصي
يحمّل ملف نصي يحتوي على جميع بيانات الإرسال.
تصدير إيميلات جاهزة
يقسم العناوين إلى مجموعات (حسب العدد المحدد) ويعرضها جاهزة للنسخ والاستخدام.
سجل الإرسال
يحفظ تاريخ جميع عمليات الإرسال مع التفاصيل (التاريخ، العدد، الحالة). يمكنك مسحه في أي وقت.
تحسين وصول البريد — تفعيل DKIM
DKIM هو توقيع رقمي يُضاف إلى رسائلك البريدية تلقائياً، يثبت لمخدمات الاستقبال (Gmail وOutlook وغيرها) أن رسائلك أصلية وليست مزيفة. تفعيله يرفع نسبة وصول رسائلك إلى صندوق الوارد بدل مجلد السبام بشكل كبير.
هل يؤثر DKIM على موقعي أو سرعة الخادم؟
لا. DKIM يعمل فقط عند إرسال البريد، وتوقيع الرسالة يحمل جزءاً من الثانية. موقعك وسرعة الخادم لا يتأثران إطلاقاً. الزبائن الذين يستخدمون الموقع لن يلاحظوا أي تغيير في الأداء.
الدخول إلى cPanel
سجّل الدخول إلى لوحة cPanel الخاصة بدومينك (مثل: yourdomain.com/cpanel).
فتح قسم Email Deliverability
من الصفحة الرئيسية في cPanel، ابحث عن قسم "Email" ثم اضغط على أيقونة "Email Deliverability" (أو قد تجدها باسم "Authentication" في بعض الإصدارات القديمة).
تفعيل DKIM
ستجد جدولاً يعرض حالة كل دومين. ابحث عن دومينك وستجد زر "Manage" بجانبه. اضغط عليه، ثم فعّل خيار DKIM. في بعض الإصدارات يكون زراً واحداً مكتوباً عليه "Enable DKIM" — اضغطه مباشرة.
نسخ سجل DNS
بعد التفعيل، cPanel سيولّد سجل TXT يحتوي على المفتاح العام. انسخ هذا السجل بالكامل (الاسم + القيمة). إذا كان cPanel يدير DNS لدومينك تلقائياً، قد يُضاف السجل تلقائياً — انتقل للخطوة 6.
إضافة السجل في DNS (إذا لزم)
إذا كان DNS لدومينك مُداراً خارج cPanel (مثل Cloudflare أو GoDaddy)، اذهب إلى لوحة إدارة DNS وأضف سجل TXT جديداً بالبيانات التي نسختها:
- الاسم (Name/Host):
default._domainkey(أو كما ظهر في cPanel) - القيمة (Value): المفتاح العام الذي نسخته (يبدأ بـ
v=DKIM1; k=rsa; p=...) - TTL: اتركه الافتراضي
احفظ السجل ثم انتظر من دقائق إلى ساعة حتى ينتشر في الإنترنت.
التحقق من نجاح التفعيل
أعد فتح "Email Deliverability" في cPanel. يجب أن تظهر حالة DKIM بجانب دومينك باللون الأخضر ومكتوباً عليها "Enabled" أو "Valid". هذا يعني أن التوقيع يعمل ورسائلك ستصل بجودة أعلى.
ماذا لو لم يكن لدي cPanel؟
إذا كنت تستخدم Gmail أو Outlook أو مزود بريد آخر، فـ DKIM يُدار تلقائياً من جانبهم ولا تحتاج لتفعيله يدوياً. هذا الدليل مخصص فقط لمستخدمي cPanel الذين يديرون خادم بريد خاصاً بهم.
النسخ الاحتياطي واسترجاع الإعدادات
يوفر النظام طريقتين لحفظ بياناتك واسترجاعها: نسخ احتياطي محلي على جهازك، ونسخ احتياطي على خادم Supabase يمكن الوصول إليه من أي جهاز.
النسخ الاحتياطي المحلي
من القائمة الجانبية، اضغط على "إنشاء نسخة احتياطية" لتنزيل ملف يحتوي على كل بيانات الموقع (الإعدادات، الرسائل، القوالب، السجل). للاسترجاع، اضغط على "استرجاع نسخة احتياطية" واختر الملف المحفوظ.
النسخ الاحتياطي في الخادم
في تبويب الإعدادات، ستجد قسماً خاصاً للنسخ الاحتياطي في الخادم. يتوفر أربعة أزرار:
حفظ الإعدادات في الخادم
يحفظ إعدادات Supabase و SMTP و Gemini ومضاد السبام فقط في الخادم. مفصل لنقل الإعدادات بين الأجهزة.
تحميل الإعدادات من الخادم
يسترجع الإعدادات المحفوظة من الخادم ويستبدل الإعدادات الحالية. يتطلب إعداد Supabase أولاً.
نسخة احتياطية كاملة في الخادم
يحفظ كل بيانات الموقع (الإعدادات + الرسائل + القوالب + السجل + كل ما هو محفوظ في المتصفح) في الخادم.
استرجاع نسخة احتياطية من الخادم
يسترجع النسخة الكاملة من الخادم ويستبدل كل البيانات المحلية. الصفحة ستُعاد تحميلها بعد الاسترجاع.
وظائف أخرى
الوضع الداكن / الفاتح
اضغط على أيقونة القمر في الزاوية السفلية لتبديل بين المظهر الداكن والفاتح.
العودة للأعلى
اضغط على السهم في الزاوية السفلية للعودة لأعلى الصفحة بسرعة.
شريط الإحصائيات
في أسفل الصفحة تجد إحصائيات مباشرة: العدد الكلي للعناوين، المرسلة، المتبقية، وعدد الرسائل المحددة، مع شريط تقدم مرئي.
الحفظ التلقائي
النظام يحفظ بياناتك تلقائياً أثناء العمل. إذا أغلقت المتصفح وفتحته مرة أخرى، ستجد بياناتك كما هي.
القائمة الجانبية
اضغط على أيقونة الخطوط الثلاثة في أعلى الصفحة لفتح قائمة جانبية تحتوي على روابط مفيدة وأدوات إضافية.
معاينة HTML
عند كتابة رسالة بتنسيق HTML، يمكنك معاينتها قبل الإرسال لترى كيف ستظهر للمستلم.