Fix email system and implement OTP confirmation flow

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>
This commit is contained in:
dwindown
2026-01-03 18:02:25 +07:00
parent 4f9a6f4ae3
commit 053465afa3
21 changed files with 1381 additions and 948 deletions

View File

@@ -0,0 +1,61 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface DeleteUserRequest {
user_id: string;
}
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const body: DeleteUserRequest = await req.json();
const { user_id } = body;
if (!user_id) {
return new Response(
JSON.stringify({ success: false, message: "user_id is required" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
console.log(`Deleting user from auth.users: ${user_id}`);
// Delete user from auth.users using admin API
const { error: deleteError } = await supabase.auth.admin.deleteUser(user_id);
if (deleteError) {
console.error('Error deleting user from auth.users:', deleteError);
throw new Error(`Failed to delete user from auth: ${deleteError.message}`);
}
console.log(`Successfully deleted user: ${user_id}`);
return new Response(
JSON.stringify({ success: true, message: "User deleted successfully" }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error deleting user:", error);
return new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -309,18 +309,30 @@ async function sendNotification(
return;
}
// Send email via Mailketing
await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email-v2`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`,
},
body: JSON.stringify({
to: data.email,
subject: template.email_subject,
html: template.email_body_html,
shortcodeData: data,
}),
});
// Send email via send-notification (which will process shortcodes and call send-email-v2)
try {
const notificationResponse = await fetch(`${Deno.env.get("SUPABASE_URL")}/functions/v1/send-notification`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
},
body: JSON.stringify({
template_key: templateKey,
recipient_email: data.email,
recipient_name: data.user_name || data.nama,
variables: data,
}),
});
if (!notificationResponse.ok) {
const errorText = await notificationResponse.text();
console.error("[HANDLE-PAID] Notification send failed:", notificationResponse.status, errorText);
} else {
const result = await notificationResponse.json();
console.log("[HANDLE-PAID] Notification sent successfully for template:", templateKey, result);
}
} catch (error) {
console.error("[HANDLE-PAID] Exception sending notification:", error);
}
}

View File

@@ -1,6 +1,5 @@
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": "*",
@@ -12,11 +11,6 @@ interface SendOTPRequest {
email: string;
}
// Generate 6-digit OTP code
function generateOTP(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
serve(async (req: Request) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
@@ -33,191 +27,88 @@ serve(async (req: Request) => {
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return new Response(
JSON.stringify({ success: false, message: "Invalid email format" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Initialize Supabase client with service role
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Generate OTP code
const otpCode = generateOTP();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes from now
// Fetch platform settings for brand name and URL
const { data: platformSettings } = await supabase
.from('platform_settings')
.select('brand_name, platform_url')
.single();
console.log(`Generating OTP for user ${user_id}, email ${email}`);
const platformName = platformSettings?.brand_name || 'ACCESS HUB';
const platformUrl = platformSettings?.platform_url || 'https://access-hub.com';
console.log(`Generating OTP for user ${user_id}`);
// Generate 6-digit OTP code
const otpCode = Math.floor(100000 + Math.random() * 900000).toString();
// Calculate expiration time (15 minutes from now)
const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString();
// Store OTP in database
const { error: otpError } = await supabase
const { error: insertError } = await supabase
.from('auth_otps')
.insert({
user_id,
email,
user_id: user_id,
email: email,
otp_code: otpCode,
expires_at: expiresAt.toISOString(),
expires_at: expiresAt,
});
if (otpError) {
console.error('Error storing OTP:', otpError);
throw new Error(`Failed to store OTP: ${otpError.message}`);
if (insertError) {
console.error('Error storing OTP:', insertError);
throw new Error(`Failed to store OTP: ${insertError.message}`);
}
// Get notification settings
const { data: settings, error: settingsError } = await supabase
.from('notification_settings')
.select('*')
.single();
console.log(`OTP generated and stored: ${otpCode}, expires at: ${expiresAt}`);
if (settingsError || !settings) {
console.error('Error fetching notification settings:', settingsError);
throw new Error('Notification settings not configured');
}
// Get platform settings for brand_name
const { data: platformSettings, error: platformError } = await supabase
.from('platform_settings')
.select('brand_name')
.single();
if (platformError) {
console.error('Error fetching platform settings:', platformError);
// Continue with fallback if platform settings not found
}
const brandName = platformSettings?.brand_name || settings.platform_name || 'ACCESS HUB';
// Get email template
console.log('Fetching email template with key: auth_email_verification');
const { data: template, error: templateError } = await supabase
.from('notification_templates')
.select('*')
.eq('key', 'auth_email_verification')
.single();
console.log('Template query result:', { template, templateError });
if (templateError || !template) {
console.error('Error fetching email template:', templateError);
throw new Error('Email template not found. Please create template with key: auth_email_verification');
}
// Get user data from auth.users
const { data: { user }, error: userError } = await supabase.auth.admin.getUserById(user_id);
if (userError || !user) {
console.error('Error fetching user:', userError);
throw new Error('User not found');
}
// Prepare template variables
const templateVars = {
platform_name: brandName,
nama: user.user_metadata?.name || user.email || 'Pengguna',
email: email,
otp_code: otpCode,
expiry_minutes: '15',
confirmation_link: '', // Not used for OTP
year: new Date().getFullYear().toString(),
};
// Process shortcodes in subject
let subject = template.email_subject;
Object.entries(templateVars).forEach(([key, value]) => {
subject = subject.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Process shortcodes in HTML body content
let htmlContent = template.email_body_html;
Object.entries(templateVars).forEach(([key, value]) => {
htmlContent = htmlContent.replace(new RegExp(`{${key}}`, 'g'), value);
});
// Wrap in master template
const htmlBody = EmailTemplateRenderer.render({
subject: subject,
content: htmlContent,
brandName: brandName,
});
// Send email via send-email-v2
console.log(`Sending OTP email to ${email}`);
console.log('Settings:', {
hasMailketingToken: !!settings.mailketing_api_token,
hasApiToken: !!settings.api_token,
hasFromName: !!settings.from_name,
hasFromEmail: !!settings.from_email,
platformName: settings.platform_name,
});
// Use api_token (not mailketing_api_token)
const apiToken = settings.api_token || settings.mailketing_api_token;
if (!apiToken) {
throw new Error('API token not found in notification_settings');
}
// Log email details (truncate HTML body for readability)
console.log('Email payload:', {
recipient: email,
from_name: settings.from_name || brandName,
from_email: settings.from_email || 'noreply@example.com',
subject: subject,
content_length: htmlBody.length,
content_preview: htmlBody.substring(0, 200),
});
const emailResponse = await fetch(`${supabaseUrl}/functions/v1/send-email-v2`, {
// Send OTP email using send-notification
const notificationUrl = `${supabaseUrl}/functions/v1/send-notification`;
const notificationResponse = await fetch(notificationUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${supabaseServiceKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipient: email,
api_token: apiToken,
from_name: settings.from_name || brandName,
from_email: settings.from_email || 'noreply@example.com',
subject: subject,
content: htmlBody,
template_key: 'auth_email_verification',
recipient_email: email,
recipient_name: email.split('@')[0],
variables: {
nama: email.split('@')[0],
otp_code: otpCode,
email: email,
user_id: user_id,
expiry_minutes: '15',
platform_name: platformName,
platform_url: platformUrl
}
}),
});
if (!emailResponse.ok) {
const errorText = await emailResponse.text();
console.error('Email send error:', emailResponse.status, errorText);
throw new Error(`Failed to send email: ${emailResponse.status} ${errorText}`);
if (!notificationResponse.ok) {
const errorText = await notificationResponse.text();
console.error('Error sending notification email:', notificationResponse.status, errorText);
throw new Error(`Failed to send OTP email: ${notificationResponse.status} ${errorText}`);
}
const emailResult = await emailResponse.json();
console.log('Email sent successfully:', emailResult);
// Note: notification_logs table doesn't exist, skipping logging
const notificationResult = await notificationResponse.json();
console.log('Notification sent successfully:', notificationResult);
return new Response(
JSON.stringify({
success: true,
message: 'OTP sent successfully'
message: "OTP sent successfully"
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending OTP:", error);
// Note: notification_logs table doesn't exist, skipping error logging
return new Response(
JSON.stringify({
success: false,

View File

@@ -1,190 +0,0 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
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 supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get current date/time in Jakarta timezone
const now = new Date();
const jakartaOffset = 7 * 60; // UTC+7
const jakartaTime = new Date(now.getTime() + jakartaOffset * 60 * 1000);
const today = jakartaTime.toISOString().split('T')[0];
// Find consultations happening in the next 24 hours that haven't been reminded
const tomorrow = new Date(jakartaTime);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split('T')[0];
console.log("Checking consultations for dates:", today, "to", tomorrowStr);
// Get confirmed slots for today and tomorrow
const { data: upcomingSlots, error: slotsError } = await supabase
.from("consulting_slots")
.select(`
*,
profiles:user_id (full_name, email)
`)
.eq("status", "confirmed")
.gte("date", today)
.lte("date", tomorrowStr)
.order("date")
.order("start_time");
if (slotsError) {
console.error("Error fetching slots:", slotsError);
throw slotsError;
}
console.log("Found upcoming slots:", upcomingSlots?.length || 0);
if (!upcomingSlots || upcomingSlots.length === 0) {
return new Response(
JSON.stringify({ success: true, message: "No upcoming consultations to remind" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get notification template for consultation reminder
const { data: template } = await supabase
.from("notification_templates")
.select("*")
.eq("key", "consulting_scheduled")
.single();
// Get SMTP settings
const { data: smtpSettings } = await supabase
.from("notification_settings")
.select("*")
.single();
// Get platform settings
const { data: platformSettings } = await supabase
.from("platform_settings")
.select("brand_name, brand_email_from_name, integration_whatsapp_number")
.single();
const results: any[] = [];
for (const slot of upcomingSlots) {
const profile = slot.profiles as any;
// Build payload for notification
const payload = {
nama: profile?.full_name || "Pelanggan",
email: profile?.email || "",
tanggal_konsultasi: new Date(slot.date).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
link_meet: slot.meet_link || "Akan diinformasikan",
topik: slot.topic_category,
catatan: slot.notes || "-",
brand_name: platformSettings?.brand_name || "LearnHub",
whatsapp: platformSettings?.integration_whatsapp_number || "",
};
// Log the reminder payload
console.log("Reminder payload for slot:", slot.id, payload);
// Update last_payload_example in template
if (template) {
await supabase
.from("notification_templates")
.update({ last_payload_example: payload })
.eq("id", template.id);
}
// Send webhook if configured
if (template?.webhook_url) {
try {
await fetch(template.webhook_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "consulting_reminder",
slot_id: slot.id,
...payload,
}),
});
console.log("Webhook sent for slot:", slot.id);
} catch (webhookError) {
console.error("Webhook error:", webhookError);
}
}
// Send email if template is active and Mailketing is configured
if (template?.is_active && smtpSettings?.api_token && profile?.email) {
try {
// Replace shortcodes in email body using master template system
let emailBody = template.email_body_html || "";
let emailSubject = template.email_subject || "Reminder Konsultasi";
Object.entries(payload).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, "g");
emailBody = emailBody.replace(regex, String(value));
emailSubject = emailSubject.replace(regex, String(value));
});
// Send via send-email-v2 (Mailketing API)
const { error: emailError } = await supabase.functions.invoke("send-email-v2", {
body: {
recipient: profile.email,
api_token: smtpSettings.api_token,
from_name: smtpSettings.from_name || platformSettings?.brand_name || "Access Hub",
from_email: smtpSettings.from_email || "noreply@with.dwindi.com",
subject: emailSubject,
content: emailBody,
},
});
if (emailError) {
console.error("Failed to send reminder email:", emailError);
} else {
console.log("Reminder email sent to:", profile.email);
}
} catch (emailError) {
console.error("Error sending reminder email:", emailError);
}
}
results.push({
slot_id: slot.id,
client: profile?.full_name,
date: slot.date,
time: slot.start_time,
reminded: true,
});
}
return new Response(
JSON.stringify({
success: true,
message: `Processed ${results.length} consultation reminders`,
results
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending reminders:", error);
return new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -1,4 +1,5 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -7,22 +8,24 @@ const corsHeaders = {
interface EmailRequest {
recipient: string;
api_token: string;
from_name: string;
from_email: string;
subject: string;
content: string;
}
// Send via Mailketing API
async function sendViaMailketing(request: EmailRequest): Promise<{ success: boolean; message: string }> {
const { recipient, api_token, from_name, from_email, subject, content } = request;
async function sendViaMailketing(
request: EmailRequest,
apiToken: string,
fromName: string,
fromEmail: string
): Promise<{ success: boolean; message: string }> {
const { recipient, subject, content } = request;
// Build form-encoded body (http_build_query format)
const params = new URLSearchParams();
params.append('api_token', api_token);
params.append('from_name', from_name);
params.append('from_email', from_email);
params.append('api_token', apiToken);
params.append('from_name', fromName);
params.append('from_email', fromEmail);
params.append('recipient', recipient);
params.append('subject', subject);
params.append('content', content);
@@ -58,19 +61,46 @@ serve(async (req: Request): Promise<Response> => {
}
try {
// Initialize Supabase client
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Fetch email settings from platform_settings
const { data: settings, error: settingsError } = await supabase
.from('platform_settings')
.select('*')
.single();
if (settingsError || !settings) {
console.error('Error fetching platform settings:', settingsError);
throw new Error('Failed to fetch email configuration from platform_settings');
}
const apiToken = settings.integration_email_api_token;
const fromName = settings.integration_email_from_name || settings.brand_name;
const fromEmail = settings.integration_email_from_email;
if (!apiToken || !fromEmail) {
return new Response(
JSON.stringify({ success: false, message: "Email not configured. Please set API token and from email in platform settings." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const body: EmailRequest = await req.json();
// Validate required fields
if (!body.recipient || !body.api_token || !body.from_name || !body.from_email || !body.subject || !body.content) {
if (!body.recipient || !body.subject || !body.content) {
return new Response(
JSON.stringify({ success: false, message: "Missing required fields: recipient, api_token, from_name, from_email, subject, content" }),
JSON.stringify({ success: false, message: "Missing required fields: recipient, subject, content" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(body.recipient) || !emailRegex.test(body.from_email)) {
if (!emailRegex.test(body.recipient) || !emailRegex.test(fromEmail)) {
return new Response(
JSON.stringify({ success: false, message: "Invalid email format" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
@@ -78,10 +108,10 @@ serve(async (req: Request): Promise<Response> => {
}
console.log(`Attempting to send email to: ${body.recipient}`);
console.log(`From: ${body.from_name} <${body.from_email}>`);
console.log(`From: ${fromName} <${fromEmail}>`);
console.log(`Subject: ${body.subject}`);
const result = await sendViaMailketing(body);
const result = await sendViaMailketing(body, apiToken, fromName, fromEmail);
return new Response(
JSON.stringify(result),

View File

@@ -1,7 +1,6 @@
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";
import QRCode from 'https://esm.sh/qrcode@1.5.3';
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -259,44 +258,29 @@ serve(async (req: Request): Promise<Response> => {
);
}
// Get platform settings
const { data: settings } = await supabase
// Get platform settings (includes email configuration)
const { data: platformSettings, error: platformError } = await supabase
.from("platform_settings")
.select("*")
.single();
if (!settings) {
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: settings.brand_name || "Platform",
platform_name: brandName,
...variables,
};
// Special handling for order_created: generate QR code image
if (template_key === 'order_created' && allVariables.qr_string) {
console.log('[SEND-NOTIFICATION] Generating QR code for order_created email');
try {
const qrDataUrl = await QRCode.toDataURL(allVariables.qr_string, {
width: 300,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' }
});
allVariables.qr_code_image = qrDataUrl;
console.log('[SEND-NOTIFICATION] QR code generated successfully');
} catch (qrError) {
console.error('[SEND-NOTIFICATION] Failed to generate QR code:', qrError);
// Continue without QR code - don't fail the email
allVariables.qr_code_image = '';
}
}
const subject = replaceVariables(template.email_subject || template.subject || "", allVariables);
const htmlContent = replaceVariables(template.email_body_html || template.body_html || template.body_text || "", allVariables);
@@ -304,67 +288,30 @@ serve(async (req: Request): Promise<Response> => {
const htmlBody = EmailTemplateRenderer.render({
subject: subject,
content: htmlContent,
brandName: settings.brand_name || "ACCESS HUB",
brandName: brandName,
});
const emailPayload: EmailPayload = {
to: recipient_email,
subject,
html: htmlBody,
from_name: settings.brand_email_from_name || settings.brand_name || "Notifikasi",
from_email: settings.smtp_from_email || "noreply@example.com",
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 = settings.integration_email_provider || "mailketing";
const provider = platformSettings.integration_email_provider || "mailketing";
console.log(`Sending email via ${provider} to ${recipient_email}`);
switch (provider) {
case "mailketing":
const mailketingToken = settings.mailketing_api_token || settings.api_token;
const mailketingToken = platformSettings.integration_email_api_token;
if (!mailketingToken) throw new Error("Mailketing API token not configured");
await sendViaMailketing(emailPayload, mailketingToken);
break;
case "smtp":
await sendViaSMTP(emailPayload, {
host: settings.smtp_host,
port: settings.smtp_port || 587,
username: settings.smtp_username,
password: settings.smtp_password,
from_name: emailPayload.from_name,
from_email: emailPayload.from_email,
use_tls: settings.smtp_use_tls ?? true,
});
break;
case "resend":
const resendKey = Deno.env.get("RESEND_API_KEY");
if (!resendKey) throw new Error("RESEND_API_KEY not configured");
await sendViaResend(emailPayload, resendKey);
break;
case "elasticemail":
const elasticKey = Deno.env.get("ELASTICEMAIL_API_KEY");
if (!elasticKey) throw new Error("ELASTICEMAIL_API_KEY not configured");
await sendViaElasticEmail(emailPayload, elasticKey);
break;
case "sendgrid":
const sendgridKey = Deno.env.get("SENDGRID_API_KEY");
if (!sendgridKey) throw new Error("SENDGRID_API_KEY not configured");
await sendViaSendGrid(emailPayload, sendgridKey);
break;
case "mailgun":
const mailgunKey = Deno.env.get("MAILGUN_API_KEY");
const mailgunDomain = Deno.env.get("MAILGUN_DOMAIN");
if (!mailgunKey || !mailgunDomain) throw new Error("MAILGUN credentials not configured");
await sendViaMailgun(emailPayload, mailgunKey, mailgunDomain);
break;
default:
throw new Error(`Unknown email provider: ${provider}`);
throw new Error(`Unknown email provider: ${provider}. Only 'mailketing' is supported.`);
}
// Log notification

View File

@@ -1,179 +0,0 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface TestEmailRequest {
to: string;
smtp_host: string;
smtp_port: number;
smtp_username: string;
smtp_password: string;
smtp_from_name: string;
smtp_from_email: string;
smtp_use_tls: boolean;
}
async function sendEmail(config: TestEmailRequest): Promise<{ success: boolean; message: string }> {
const { to, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from_name, smtp_from_email, smtp_use_tls } = config;
// Build email content
const boundary = "----=_Part_" + Math.random().toString(36).substr(2, 9);
const emailContent = [
`From: "${smtp_from_name}" <${smtp_from_email}>`,
`To: ${to}`,
`Subject: =?UTF-8?B?${btoa("Email Uji Coba - Konfigurasi SMTP Berhasil")}?=`,
`MIME-Version: 1.0`,
`Content-Type: multipart/alternative; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=UTF-8`,
``,
`Ini adalah email uji coba dari sistem notifikasi Anda.`,
`Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.`,
``,
`--${boundary}`,
`Content-Type: text/html; charset=UTF-8`,
``,
`<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #0066cc;">Email Uji Coba Berhasil! ✓</h2>
<p>Ini adalah email uji coba dari sistem notifikasi Anda.</p>
<p>Jika Anda menerima email ini, konfigurasi SMTP Anda sudah benar.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<p style="font-size: 12px; color: #666;">
Dikirim dari: ${smtp_from_email}<br>
Server: ${smtp_host}:${smtp_port}
</p>
</div>
</body>
</html>`,
`--${boundary}--`,
].join("\r\n");
// Connect to SMTP server
const conn = smtp_use_tls
? await Deno.connectTls({ hostname: smtp_host, port: smtp_port })
: await Deno.connect({ hostname: smtp_host, port: smtp_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 {
// Read greeting
await readResponse();
// EHLO
let response = await sendCommand(`EHLO localhost`);
console.log("EHLO response:", response);
// For non-TLS connection on port 587, we may need STARTTLS
if (!smtp_use_tls && response.includes("STARTTLS")) {
await sendCommand("STARTTLS");
// Upgrade to TLS - not supported in basic Deno.connect
// For now, recommend using TLS directly
}
// AUTH LOGIN
response = await sendCommand("AUTH LOGIN");
console.log("AUTH response:", response);
// Username (base64)
response = await sendCommand(btoa(smtp_username));
console.log("Username response:", response);
// Password (base64)
response = await sendCommand(btoa(smtp_password));
console.log("Password response:", response);
if (!response.includes("235") && !response.includes("Authentication successful")) {
throw new Error("Authentication failed: " + response);
}
// MAIL FROM
response = await sendCommand(`MAIL FROM:<${smtp_from_email}>`);
if (!response.includes("250")) {
throw new Error("MAIL FROM failed: " + response);
}
// RCPT TO
response = await sendCommand(`RCPT TO:<${to}>`);
if (!response.includes("250")) {
throw new Error("RCPT TO failed: " + response);
}
// DATA
response = await sendCommand("DATA");
if (!response.includes("354")) {
throw new Error("DATA failed: " + response);
}
// Send email content
await conn.write(encoder.encode(emailContent + "\r\n.\r\n"));
response = await readResponse();
if (!response.includes("250")) {
throw new Error("Email send failed: " + response);
}
// QUIT
await sendCommand("QUIT");
conn.close();
return { success: true, message: "Email uji coba berhasil dikirim ke " + to };
} catch (error) {
conn.close();
throw error;
}
}
serve(async (req: Request): Promise<Response> => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const body: TestEmailRequest = await req.json();
// Validate required fields
if (!body.to || !body.smtp_host || !body.smtp_username || !body.smtp_password) {
return new Response(
JSON.stringify({ success: false, message: "Missing required fields" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
console.log("Attempting to send test email to:", body.to);
console.log("SMTP config:", { host: body.smtp_host, port: body.smtp_port, user: body.smtp_username });
const result = await sendEmail(body);
return new Response(
JSON.stringify(result),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending test email:", error);
return new Response(
JSON.stringify({ success: false, message: error.message || "Failed to send email" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -0,0 +1,54 @@
-- ============================================================================
-- Update Auth OTP Email Template - Better Copywriting with Dedicated Page Link
-- ============================================================================
-- Update auth_email_verification template with improved copywriting
UPDATE notification_templates
SET
email_subject = 'Konfirmasi Email Anda - {platform_name}',
email_body_html = '---
<h1>🔐 Konfirmasi Alamat Email</h1>
<p>Selamat datang di <strong>{platform_name}</strong>!</p>
<p>Terima kasih telah mendaftar. Untuk mengaktifkan akun Anda, masukkan kode verifikasi 6 digit berikut:</p>
<div class="otp-box">{otp_code}</div>
<p><strong>⏰ Berlaku selama {expiry_minutes} menit</strong></p>
<h2>🎯 Cara Verifikasi:</h2>
<ol>
<li><strong>Kembali ke halaman pendaftaran</strong> - Form OTP sudah otomatis muncul</li>
<li><strong>Masukkan kode 6 digit</strong> di atas pada kolom verifikasi</li>
<li><strong>Klik "Verifikasi Email"</strong> dan akun Anda siap digunakan!</li>
</ol>
<h2>🔄 Halaman Khusus Verifikasi</h2>
<p>Jika Anda kehilangan halaman pendaftaran atau tertutup tidak sengaja, jangan khawatir! Anda tetap bisa memverifikasi akun melalui:</p>
<p class="text-center" style="margin: 20px 0;">
<a href="{platform_url}/confirm-otp?user_id={user_id}&email={email}" class="button" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
📧 Buka Halaman Verifikasi Khusus
</a>
</p>
<p style="font-size: 14px; color: #666;">
<em>Link ini akan membawa Anda ke halaman khusus untuk memasukkan kode verifikasi.</em>
</p>
<div class="alert-warning" style="margin: 20px 0; padding: 15px; background-color: #fff3cd; border-left: 4px solid #ffc107;">
<p style="margin: 0;"><strong>💡 Tips:</strong> Cek folder <em>Spam</em> atau <em>Promotions</em> jika email tidak muncul di inbox dalam 1-2 menit.</p>
</div>
<blockquote class="alert-info">
<strong> Info:</strong> Jika Anda tidak merasa mendaftar di {platform_name}, abaikan email ini dengan aman.
</blockquote>
---'
WHERE key = 'auth_email_verification';
-- Return success message
DO $$
BEGIN
RAISE NOTICE 'Auth email template updated with improved copywriting and dedicated page link';
END $$;

View File

@@ -1,5 +1,5 @@
-- Update order_created email template to include QR code
-- This migration adds the QR code section to the order confirmation email
-- Update order_created email template to remove QR code
-- QR code is now displayed on the order detail page instead
UPDATE notification_templates
SET
@@ -12,28 +12,6 @@ SET
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
<!-- QR Code Section -->
<div style="text-align: center; margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; font-size: 18px; color: #333;">Scan QR untuk Pembayaran</h3>
<!-- QR Code Image -->
<img src="{qr_code_image}" alt="QRIS Payment QR Code" style="width: 200px; height: 200px; border: 2px solid #000; padding: 10px; background-color: #fff; display: inline-block;" />
<p style="margin: 15px 0 5px 0; font-size: 14px; color: #666;">
Scan dengan aplikasi e-wallet atau mobile banking Anda
</p>
<p style="margin: 5px 0 0 0; font-size: 12px; color: #999;">
Berlaku hingga: {qr_expiry_time}
</p>
<div style="margin-top: 15px;">
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Bayar Sekarang
</a>
</div>
</div>
<!-- Order Summary Section -->
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
@@ -59,6 +37,16 @@ SET
</p>
</div>
<div style="margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px; text-align: center;">
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah:
</p>
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Bayar Sekarang
</a>
</div>
<p style="font-size: 14px; color: #666;">
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
</p>

View File

@@ -0,0 +1,15 @@
-- ============================================================================
-- Add platform_url column to platform_settings
-- ============================================================================
-- Add platform_url column if it doesn't exist
ALTER TABLE platform_settings
ADD COLUMN IF NOT EXISTS platform_url TEXT;
-- Set default value if null
UPDATE platform_settings
SET platform_url = 'https://access-hub.com'
WHERE platform_url IS NULL;
-- Add comment
COMMENT ON COLUMN platform_settings.platform_url IS 'Base URL of the platform (used for email links)';

View File

@@ -0,0 +1,48 @@
-- Add test_email template for "Uji Coba Email" button in Integrasi tab
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
VALUES (
'test_email',
'Test Email',
'Email Test - {platform_name}',
'
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Email Test - {platform_name}</h2>
<p>Halo,</p>
<p>Ini adalah email tes dari sistem <strong>{platform_name}</strong>.</p>
<div style="margin: 20px 0; padding: 15px; background-color: #f0f0f0; border-left: 4px solid #000;">
<p style="margin: 0; font-size: 14px;">
<strong>✓ Konfigurasi email berhasil!</strong><br>
Email Anda telah terkirim dengan benar menggunakan provider: <strong>Mailketing</strong>
</p>
</div>
<p style="font-size: 14px; color: #666;">
Jika Anda menerima email ini, berarti konfigurasi email sudah benar.
</p>
<p style="font-size: 14px;">
Terima kasih,<br>
Tim {platform_name}
</p>
</div>
',
true,
NOW(),
NOW()
)
ON CONFLICT (key) DO UPDATE SET
email_subject = EXCLUDED.email_subject,
email_body_html = EXCLUDED.email_body_html,
updated_at = NOW();
-- Verify the template
SELECT
key,
name,
email_subject,
is_active
FROM notification_templates
WHERE key = 'test_email';

View File

@@ -0,0 +1,197 @@
-- ============================================================================
-- Fix Email Templates: Use Short Order ID and Add Missing Links
-- ============================================================================
-- 1. Fix order_created template - use short order_id and fix subject
UPDATE notification_templates
SET
email_subject = 'Konfirmasi Pesanan - #{order_id_short}',
email_body_html = '---
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #333;">Konfirmasi Pesanan</h2>
<p>Halo {nama},</p>
<p>Terima kasih telah melakukan pemesanan! Berikut adalah detail pesanan Anda:</p>
<!-- Order Summary Section -->
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Order ID:</strong> #{order_id_short}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Tanggal:</strong> {tanggal_pesanan}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Produk:</strong> {produk}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
</p>
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #000;">
Total: {total}
</p>
</div>
<div style="margin: 30px 0; padding: 20px; background-color: #f5f5f5; border-radius: 8px; text-align: center;">
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
Silakan selesaikan pembayaran Anda dengan mengklik tombol di bawah:
</p>
<a href="{payment_link}" style="display: inline-block; padding: 12px 24px; background-color: #000; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Bayar Sekarang
</a>
</div>
<p style="font-size: 14px; color: #666;">
Silakan selesaikan pembayaran Anda sebelum waktu habis. Setelah pembayaran berhasil, akses ke produk akan segera aktif.
</p>
<p style="font-size: 14px;">
Jika Anda memiliki pertanyaan, jangan ragu untuk menghubungi kami.
</p>
<p style="font-size: 14px;">
Terima kasih,<br>
Tim {platform_name}
</p>
</div>
---',
updated_at = NOW()
WHERE key = 'order_created';
-- 2. Create or update payment_success template
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
VALUES (
'payment_success',
'Payment Success Email',
'Pembayaran Berhasil - Order #{order_id_short}',
'---
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #28a745;">Pembayaran Berhasil! ✓</h2>
<p>Halo {nama},</p>
<p>Terima kasih! Pembayaran Anda telah berhasil dikonfirmasi.</p>
<div style="margin: 30px 0; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Detail Pesanan</h3>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Order ID:</strong> #{order_id_short}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Tanggal:</strong> {tanggal_pesanan}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Produk:</strong> {produk}
</p>
<p style="margin: 5px 0; font-size: 14px;">
<strong>Metode Pembayaran:</strong> {metode_pembayaran}
</p>
<p style="margin: 15px 0 5px 0; font-size: 16px; font-weight: bold; color: #28a745;">
Total: {total}
</p>
</div>
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
Akses ke produk Anda sudah aktif! Klik tombol di bawah untuk mulai belajar:
</p>
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Akses Sekarang
</a>
</div>
<p style="font-size: 14px; color: #666;">
Jika Anda mengalami masalah saat mengakses produk, jangan ragu untuk menghubungi kami.
</p>
<p style="font-size: 14px;">
Selamat belajar!<br>
Tim {platform_name}
</p>
</div>
---',
true,
NOW(),
NOW()
)
ON CONFLICT (key) DO UPDATE SET
email_subject = EXCLUDED.email_subject,
email_body_html = EXCLUDED.email_body_html,
is_active = EXCLUDED.is_active,
updated_at = NOW();
-- 3. Create or update access_granted template
INSERT INTO notification_templates (key, name, email_subject, email_body_html, is_active, created_at, updated_at)
VALUES (
'access_granted',
'Access Granted Email',
'Akses Produk Diberikan - {produk}',
'---
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #28a745;">Akses Produk Aktif! 🎉</h2>
<p>Halo {nama},</p>
<p>Selamat! Akses ke produk Anda telah diaktifkan.</p>
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border: 1px solid #b3d9ff; border-radius: 8px;">
<h3 style="margin: 0 0 15px 0; font-size: 16px; color: #333;">Produk Anda:</h3>
<p style="margin: 5px 0; font-size: 14px;">
<strong>{produk}</strong>
</p>
</div>
<div style="margin: 30px 0; padding: 20px; background-color: #f0f8ff; border-radius: 8px; text-align: center;">
<p style="margin: 0 0 15px 0; font-size: 14px; color: #666;">
Mulai belajar sekarang dengan mengklik tombol di bawah:
</p>
<a href="{link_akses}" style="display: inline-block; padding: 12px 24px; background-color: #28a745; color: #fff; text-decoration: none; border-radius: 4px; font-weight: bold;">
Akses Sekarang
</a>
</div>
<p style="font-size: 14px; color: #666;">
Nikmati pembelajaran Anda! Jika ada pertanyaan, jangan ragu untuk menghubungi kami.
</p>
<p style="font-size: 14px;">
Happy learning!<br>
Tim {platform_name}
</p>
</div>
---',
true,
NOW(),
NOW()
)
ON CONFLICT (key) DO UPDATE SET
email_subject = EXCLUDED.email_subject,
email_body_html = EXCLUDED.email_body_html,
is_active = EXCLUDED.is_active,
updated_at = NOW();
-- Verify updates
SELECT
key,
email_subject,
is_active,
updated_at
FROM notification_templates
WHERE key IN ('order_created', 'payment_success', 'access_granted')
ORDER BY key;

View File

@@ -218,11 +218,7 @@ export class EmailTemplateRenderer {
<tr>
<td align="left" style="font-size: 12px; line-height: 18px; font-family: monospace; color: #555;">
<p style="margin: 0 0 10px 0; font-weight: bold;">{{brandName}}</p>
<p style="margin: 0 0 15px 0;">Email ini dikirim otomatis. Jangan membalas email ini.</p>
<p style="margin: 0;">
<a href="#" style="color: #000; text-decoration: underline;">Ubah Preferensi</a> &nbsp;|&nbsp;
<a href="#" style="color: #000; text-decoration: underline;">Unsubscribe</a>
</p>
<p style="margin: 0;">Email ini dikirim otomatis. Jangan membalas email ini.</p>
</td>
</tr>
</table>