Email System Fixes: - Fix email sending after payment: handle-order-paid now calls send-notification instead of send-email-v2 directly, properly processing template variables - Fix order_created email timing: sent immediately after order creation, before payment QR code generation - Update email templates to use short order ID (8 chars) instead of full UUID - Add working "Akses Sekarang" buttons to payment_success and access_granted emails - Add platform_url column to platform_settings for email links OTP Verification Flow: - Create dedicated /confirm-otp page for users who close registration modal - Add link in checkout modal and email to dedicated OTP page - Update OTP email template with better copywriting and dedicated page link - Fix send-auth-otp to fetch platform settings for dynamic brand_name and platform_url - Auto-login users after OTP verification in checkout flow Admin Features: - Add delete user functionality with cascade deletion of all related data - Update IntegrasiTab to read/write email settings from platform_settings only - Add test email template for email configuration testing Cleanup: - Remove obsolete send-consultation-reminder and send-test-email functions - Update send-email-v2 to read email config from platform_settings - Remove footer links (Ubah Preferensi/Unsubscribe) from email templates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
340 lines
10 KiB
TypeScript
340 lines
10 KiB
TypeScript
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
|
import { EmailTemplateRenderer } from "../shared/email-template-renderer.ts";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
};
|
|
|
|
interface NotificationRequest {
|
|
template_key: string;
|
|
recipient_email: string;
|
|
recipient_name?: string;
|
|
variables?: Record<string, string>;
|
|
}
|
|
|
|
interface SMTPConfig {
|
|
host: string;
|
|
port: number;
|
|
username: string;
|
|
password: string;
|
|
from_name: string;
|
|
from_email: string;
|
|
use_tls: boolean;
|
|
}
|
|
|
|
interface EmailPayload {
|
|
to: string;
|
|
subject: string;
|
|
html: string;
|
|
from_name: string;
|
|
from_email: string;
|
|
}
|
|
|
|
// Send via Mailketing API
|
|
async function sendViaMailketing(payload: EmailPayload, apiToken: string): Promise<void> {
|
|
const params = new URLSearchParams();
|
|
params.append('api_token', apiToken);
|
|
params.append('from_name', payload.from_name);
|
|
params.append('from_email', payload.from_email);
|
|
params.append('recipient', payload.to);
|
|
params.append('subject', payload.subject);
|
|
params.append('content', payload.html);
|
|
|
|
console.log(`Sending email via Mailketing to ${payload.to}`);
|
|
|
|
const response = await fetch('https://api.mailketing.co.id/api/v1/send', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: params.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Mailketing API error:', response.status, errorText);
|
|
throw new Error(`Mailketing API error: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log('Mailketing API response:', result);
|
|
}
|
|
|
|
// Send via SMTP
|
|
async function sendViaSMTP(payload: EmailPayload, config: SMTPConfig): Promise<void> {
|
|
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
|
|
const emailContent = [
|
|
`From: "${payload.from_name}" <${payload.from_email}>`,
|
|
`To: ${payload.to}`,
|
|
`Subject: =?UTF-8?B?${btoa(payload.subject)}?=`,
|
|
`MIME-Version: 1.0`,
|
|
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
|
``,
|
|
`--${boundary}`,
|
|
`Content-Type: text/html; charset=UTF-8`,
|
|
``,
|
|
payload.html,
|
|
`--${boundary}--`,
|
|
].join("\r\n");
|
|
|
|
const conn = config.use_tls
|
|
? await Deno.connectTls({ hostname: config.host, port: config.port })
|
|
: await Deno.connect({ hostname: config.host, port: config.port });
|
|
|
|
const encoder = new TextEncoder();
|
|
const decoder = new TextDecoder();
|
|
|
|
async function readResponse(): Promise<string> {
|
|
const buffer = new Uint8Array(1024);
|
|
const n = await conn.read(buffer);
|
|
if (n === null) return "";
|
|
return decoder.decode(buffer.subarray(0, n));
|
|
}
|
|
|
|
async function sendCommand(cmd: string): Promise<string> {
|
|
await conn.write(encoder.encode(cmd + "\r\n"));
|
|
return await readResponse();
|
|
}
|
|
|
|
try {
|
|
await readResponse();
|
|
await sendCommand(`EHLO localhost`);
|
|
await sendCommand("AUTH LOGIN");
|
|
await sendCommand(btoa(config.username));
|
|
const authResponse = await sendCommand(btoa(config.password));
|
|
|
|
if (!authResponse.includes("235") && !authResponse.includes("Authentication successful")) {
|
|
throw new Error("SMTP Authentication failed");
|
|
}
|
|
|
|
await sendCommand(`MAIL FROM:<${payload.from_email}>`);
|
|
await sendCommand(`RCPT TO:<${payload.to}>`);
|
|
await sendCommand("DATA");
|
|
await conn.write(encoder.encode(emailContent + "\r\n.\r\n"));
|
|
await readResponse();
|
|
await sendCommand("QUIT");
|
|
conn.close();
|
|
} catch (error) {
|
|
conn.close();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Send via Resend
|
|
async function sendViaResend(payload: EmailPayload, apiKey: string): Promise<void> {
|
|
const response = await fetch("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
from: `${payload.from_name} <${payload.from_email}>`,
|
|
to: [payload.to],
|
|
subject: payload.subject,
|
|
html: payload.html,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Resend error: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Send via ElasticEmail
|
|
async function sendViaElasticEmail(payload: EmailPayload, apiKey: string): Promise<void> {
|
|
const response = await fetch("https://api.elasticemail.com/v4/emails/transactional", {
|
|
method: "POST",
|
|
headers: {
|
|
"X-ElasticEmail-ApiKey": apiKey,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
Recipients: {
|
|
To: [payload.to],
|
|
},
|
|
Content: {
|
|
From: `${payload.from_name} <${payload.from_email}>`,
|
|
Subject: payload.subject,
|
|
Body: [
|
|
{
|
|
ContentType: "HTML",
|
|
Content: payload.html,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`ElasticEmail error: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Send via SendGrid
|
|
async function sendViaSendGrid(payload: EmailPayload, apiKey: string): Promise<void> {
|
|
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
personalizations: [{ to: [{ email: payload.to }] }],
|
|
from: { email: payload.from_email, name: payload.from_name },
|
|
subject: payload.subject,
|
|
content: [{ type: "text/html", value: payload.html }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`SendGrid error: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Send via Mailgun
|
|
async function sendViaMailgun(payload: EmailPayload, apiKey: string, domain: string): Promise<void> {
|
|
const formData = new FormData();
|
|
formData.append("from", `${payload.from_name} <${payload.from_email}>`);
|
|
formData.append("to", payload.to);
|
|
formData.append("subject", payload.subject);
|
|
formData.append("html", payload.html);
|
|
|
|
const response = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Basic ${btoa(`api:${apiKey}`)}`,
|
|
},
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`Mailgun error: ${error}`);
|
|
}
|
|
}
|
|
|
|
function replaceVariables(template: string, variables: Record<string, string>): string {
|
|
let result = template;
|
|
for (const [key, value] of Object.entries(variables)) {
|
|
// Support both {key} and {{key}} formats
|
|
result = result.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
|
result = result.replace(new RegExp(`{${key}}`, 'g'), value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
serve(async (req: Request): Promise<Response> => {
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
try {
|
|
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
|
|
const supabaseKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
|
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
|
|
const body: NotificationRequest = await req.json();
|
|
const { template_key, recipient_email, recipient_name, variables = {} } = body;
|
|
|
|
// Get notification template
|
|
const { data: template, error: templateError } = await supabase
|
|
.from("notification_templates")
|
|
.select("*")
|
|
.eq("key", template_key)
|
|
.eq("is_active", true)
|
|
.single();
|
|
|
|
if (templateError || !template) {
|
|
console.log(`Template not found: ${template_key}`);
|
|
return new Response(
|
|
JSON.stringify({ success: false, message: "Template not found or inactive" }),
|
|
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Get platform settings (includes email configuration)
|
|
const { data: platformSettings, error: platformError } = await supabase
|
|
.from("platform_settings")
|
|
.select("*")
|
|
.single();
|
|
|
|
if (platformError || !platformSettings) {
|
|
console.error('Error fetching platform settings:', platformError);
|
|
return new Response(
|
|
JSON.stringify({ success: false, message: "Platform settings not configured" }),
|
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
const brandName = platformSettings.brand_name || "ACCESS HUB";
|
|
|
|
// Build email payload
|
|
const allVariables = {
|
|
recipient_name: recipient_name || "Pelanggan",
|
|
platform_name: brandName,
|
|
...variables,
|
|
};
|
|
|
|
const subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
|
|
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables);
|
|
|
|
// Wrap with master template for consistent branding
|
|
const htmlBody = EmailTemplateRenderer.render({
|
|
subject: subject,
|
|
content: htmlContent,
|
|
brandName: brandName,
|
|
});
|
|
|
|
const emailPayload: EmailPayload = {
|
|
to: recipient_email,
|
|
subject,
|
|
html: htmlBody,
|
|
from_name: platformSettings.integration_email_from_name || brandName || "Notifikasi",
|
|
from_email: platformSettings.integration_email_from_email || "noreply@example.com",
|
|
};
|
|
|
|
// Determine provider and send
|
|
const provider = platformSettings.integration_email_provider || "mailketing";
|
|
console.log(`Sending email via ${provider} to ${recipient_email}`);
|
|
|
|
switch (provider) {
|
|
case "mailketing":
|
|
const mailketingToken = platformSettings.integration_email_api_token;
|
|
if (!mailketingToken) throw new Error("Mailketing API token not configured");
|
|
await sendViaMailketing(emailPayload, mailketingToken);
|
|
break;
|
|
|
|
default:
|
|
throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
|
|
}
|
|
|
|
// Log notification
|
|
await supabase.from("notification_logs").insert({
|
|
template_id: template.id,
|
|
recipient_email,
|
|
channel: "email",
|
|
status: "sent",
|
|
sent_at: new Date().toISOString(),
|
|
});
|
|
|
|
console.log(`Email sent successfully to ${recipient_email}`);
|
|
|
|
return new Response(
|
|
JSON.stringify({ success: true, message: "Notification sent" }),
|
|
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
} catch (error: any) {
|
|
console.error("Error sending notification:", error);
|
|
return new Response(
|
|
JSON.stringify({ success: false, message: error.message }),
|
|
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
});
|