Changes
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
|||||||
Home,
|
Home,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
X,
|
X,
|
||||||
|
Video,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -40,6 +41,7 @@ const adminNavItems: NavItem[] = [
|
|||||||
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
|
||||||
{ label: 'Produk', href: '/admin/products', icon: Package },
|
{ label: 'Produk', href: '/admin/products', icon: Package },
|
||||||
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
{ label: 'Bootcamp', href: '/admin/bootcamp', icon: BookOpen },
|
||||||
|
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
|
||||||
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
{ label: 'Order', href: '/admin/orders', icon: Receipt },
|
||||||
{ label: 'Member', href: '/admin/members', icon: Users },
|
{ label: 'Member', href: '/admin/members', icon: Users },
|
||||||
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
|
||||||
@@ -152,6 +154,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t-2 border-border space-y-2">
|
<div className="p-4 border-t-2 border-border space-y-2">
|
||||||
|
{!isAdmin && (
|
||||||
<Link to="/checkout" className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium">
|
<Link to="/checkout" className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium">
|
||||||
<ShoppingCart className="w-5 h-5" />
|
<ShoppingCart className="w-5 h-5" />
|
||||||
Keranjang
|
Keranjang
|
||||||
@@ -161,6 +164,7 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium w-full text-left"
|
className="flex items-center gap-3 px-3 py-2 hover:bg-muted text-sm font-medium w-full text-left"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Image as ImageIcon, Heading1, Heading2, Undo, Redo
|
Image as ImageIcon, Heading1, Heading2, Undo, Redo
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
@@ -45,6 +45,13 @@ export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten.
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync content when it changes externally (e.g., when editing different items)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && content !== editor.getHTML()) {
|
||||||
|
editor.commands.setContent(content || '');
|
||||||
|
}
|
||||||
|
}, [content, editor]);
|
||||||
|
|
||||||
const addLink = useCallback(() => {
|
const addLink = useCallback(() => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const url = window.prompt('Masukkan URL:');
|
const url = window.prompt('Masukkan URL:');
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function NotifikasiTab() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testEmail, setTestEmail] = useState('');
|
const [testEmail, setTestEmail] = useState('');
|
||||||
const [expandedTemplates, setExpandedTemplates] = useState<Set<string>>(new Set());
|
const [expandedTemplates, setExpandedTemplates] = useState<Set<string>>(new Set());
|
||||||
|
const [sendingTest, setSendingTest] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@@ -156,8 +157,36 @@ export function NotifikasiTab() {
|
|||||||
|
|
||||||
const sendTestEmail = async () => {
|
const sendTestEmail = async () => {
|
||||||
if (!testEmail) return toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
if (!testEmail) return toast({ title: 'Error', description: 'Masukkan email tujuan', variant: 'destructive' });
|
||||||
console.log('Test email would be sent to:', testEmail, 'with SMTP config:', smtp);
|
if (!isSmtpConfigured) return toast({ title: 'Error', description: 'Lengkapi konfigurasi SMTP terlebih dahulu', variant: 'destructive' });
|
||||||
toast({ title: 'Info', description: `Email uji coba akan dikirim ke ${testEmail} (fitur sedang dikembangkan)` });
|
|
||||||
|
setSendingTest(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.functions.invoke('send-test-email', {
|
||||||
|
body: {
|
||||||
|
to: testEmail,
|
||||||
|
smtp_host: smtp.smtp_host,
|
||||||
|
smtp_port: smtp.smtp_port,
|
||||||
|
smtp_username: smtp.smtp_username,
|
||||||
|
smtp_password: smtp.smtp_password,
|
||||||
|
smtp_from_name: smtp.smtp_from_name,
|
||||||
|
smtp_from_email: smtp.smtp_from_email,
|
||||||
|
smtp_use_tls: smtp.smtp_use_tls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
if (data?.success) {
|
||||||
|
toast({ title: 'Berhasil', description: data.message });
|
||||||
|
} else {
|
||||||
|
throw new Error(data?.message || 'Failed to send test email');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Test email error:', error);
|
||||||
|
toast({ title: 'Error', description: error.message || 'Gagal mengirim email uji coba', variant: 'destructive' });
|
||||||
|
} finally {
|
||||||
|
setSendingTest(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTemplate = async (template: NotificationTemplate) => {
|
const updateTemplate = async (template: NotificationTemplate) => {
|
||||||
@@ -278,9 +307,9 @@ export function NotifikasiTab() {
|
|||||||
placeholder="Email uji coba"
|
placeholder="Email uji coba"
|
||||||
className="border-2 max-w-xs"
|
className="border-2 max-w-xs"
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" onClick={sendTestEmail} className="border-2">
|
<Button variant="outline" onClick={sendTestEmail} className="border-2" disabled={sendingTest}>
|
||||||
<Send className="w-4 h-4 mr-2" />
|
<Send className="w-4 h-4 mr-2" />
|
||||||
Kirim Email Uji Coba
|
{sendingTest ? 'Mengirim...' : 'Kirim Email Uji Coba'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
|
|||||||
|
|
||||||
interface Product { id: string; title: string; slug: string; type: string; description: string; content: string; meeting_link: string | null; recording_url: string | null; price: number; sale_price: number | null; is_active: boolean; }
|
interface Product { id: string; title: string; slug: string; type: string; description: string; content: string; meeting_link: string | null; recording_url: string | null; price: number; sale_price: number | null; is_active: boolean; }
|
||||||
|
|
||||||
const emptyProduct = { title: '', slug: '', type: 'consulting', description: '', content: '', meeting_link: '', recording_url: '', price: 0, sale_price: null as number | null, is_active: true };
|
const emptyProduct = { title: '', slug: '', type: 'webinar', description: '', content: '', meeting_link: '', recording_url: '', price: 0, sale_price: null as number | null, is_active: true };
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
@@ -102,7 +102,7 @@ export default function Admin() {
|
|||||||
<div className="space-y-2"><Label>Title *</Label><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" /></div>
|
<div className="space-y-2"><Label>Title *</Label><Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value, slug: generateSlug(e.target.value) })} className="border-2" /></div>
|
||||||
<div className="space-y-2"><Label>Slug *</Label><Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" /></div>
|
<div className="space-y-2"><Label>Slug *</Label><Input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="border-2" /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2"><Label>Type</Label><Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}><SelectTrigger className="border-2"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="consulting">Consulting</SelectItem><SelectItem value="webinar">Webinar</SelectItem><SelectItem value="bootcamp">Bootcamp</SelectItem></SelectContent></Select></div>
|
<div className="space-y-2"><Label>Type</Label><Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}><SelectTrigger className="border-2"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="webinar">Webinar</SelectItem><SelectItem value="bootcamp">Bootcamp</SelectItem></SelectContent></Select></div>
|
||||||
<div className="space-y-2"><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border-2" rows={2} /></div>
|
<div className="space-y-2"><Label>Description</Label><Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border-2" rows={2} /></div>
|
||||||
<div className="space-y-2"><Label>Content (HTML)</Label><Textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} className="border-2 font-mono text-sm" rows={6} /></div>
|
<div className="space-y-2"><Label>Content (HTML)</Label><Textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} className="border-2 font-mono text-sm" rows={6} /></div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { formatIDR } from '@/lib/format';
|
import { formatIDR } from '@/lib/format';
|
||||||
import { Video, Clock, Calendar as CalendarIcon, MessageSquare } from 'lucide-react';
|
import { Video, Clock, Calendar as CalendarIcon, MessageSquare } from 'lucide-react';
|
||||||
import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays } from 'date-fns';
|
import { format, addMinutes, parse, isAfter, isBefore, startOfDay, addDays, isSameDay } from 'date-fns';
|
||||||
import { id } from 'date-fns/locale';
|
import { id } from 'date-fns/locale';
|
||||||
|
|
||||||
interface ConsultingSettings {
|
interface ConsultingSettings {
|
||||||
@@ -106,6 +106,8 @@ export default function ConsultingBooking() {
|
|||||||
|
|
||||||
const slots: TimeSlot[] = [];
|
const slots: TimeSlot[] = [];
|
||||||
const duration = settings.consulting_block_duration_minutes;
|
const duration = settings.consulting_block_duration_minutes;
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = isSameDay(selectedDate, now);
|
||||||
|
|
||||||
for (const wh of dayWorkhours) {
|
for (const wh of dayWorkhours) {
|
||||||
let current = parse(wh.start_time, 'HH:mm:ss', selectedDate);
|
let current = parse(wh.start_time, 'HH:mm:ss', selectedDate);
|
||||||
@@ -122,10 +124,13 @@ export default function ConsultingBooking() {
|
|||||||
return !(slotEnd <= csStart || slotStart >= csEnd);
|
return !(slotEnd <= csStart || slotStart >= csEnd);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if slot is in the past for today
|
||||||
|
const isPassed = isToday && isBefore(current, now);
|
||||||
|
|
||||||
slots.push({
|
slots.push({
|
||||||
start: slotStart,
|
start: slotStart,
|
||||||
end: slotEnd,
|
end: slotEnd,
|
||||||
available: !isConflict,
|
available: !isConflict && !isPassed,
|
||||||
});
|
});
|
||||||
|
|
||||||
current = addMinutes(current, duration);
|
current = addMinutes(current, duration);
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ interface Product {
|
|||||||
price: number;
|
price: number;
|
||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
consulting_duration_minutes: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyProduct = {
|
const emptyProduct = {
|
||||||
title: '',
|
title: '',
|
||||||
slug: '',
|
slug: '',
|
||||||
type: 'consulting',
|
type: 'webinar',
|
||||||
description: '',
|
description: '',
|
||||||
content: '',
|
content: '',
|
||||||
meeting_link: '',
|
meeting_link: '',
|
||||||
@@ -46,7 +45,6 @@ const emptyProduct = {
|
|||||||
price: 0,
|
price: 0,
|
||||||
sale_price: null as number | null,
|
sale_price: null as number | null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
consulting_duration_minutes: 60,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminProducts() {
|
export default function AdminProducts() {
|
||||||
@@ -89,7 +87,6 @@ export default function AdminProducts() {
|
|||||||
price: product.price,
|
price: product.price,
|
||||||
sale_price: product.sale_price,
|
sale_price: product.sale_price,
|
||||||
is_active: product.is_active,
|
is_active: product.is_active,
|
||||||
consulting_duration_minutes: product.consulting_duration_minutes || 60,
|
|
||||||
});
|
});
|
||||||
setActiveTab('details');
|
setActiveTab('details');
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
@@ -119,7 +116,6 @@ export default function AdminProducts() {
|
|||||||
price: form.price,
|
price: form.price,
|
||||||
sale_price: form.sale_price || null,
|
sale_price: form.sale_price || null,
|
||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
consulting_duration_minutes: form.type === 'consulting' ? form.consulting_duration_minutes : null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingProduct) {
|
if (editingProduct) {
|
||||||
@@ -246,23 +242,11 @@ export default function AdminProducts() {
|
|||||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||||
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="border-2"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="consulting">Consulting</SelectItem>
|
|
||||||
<SelectItem value="webinar">Webinar</SelectItem>
|
<SelectItem value="webinar">Webinar</SelectItem>
|
||||||
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
<SelectItem value="bootcamp">Bootcamp</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{form.type === 'consulting' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Durasi Konsultasi (menit)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={form.consulting_duration_minutes || 60}
|
|
||||||
onChange={(e) => setForm({ ...form, consulting_duration_minutes: parseInt(e.target.value) || 60 })}
|
|
||||||
className="border-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Deskripsi</Label>
|
<Label>Deskripsi</Label>
|
||||||
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
<RichTextEditor content={form.description} onChange={(v) => setForm({ ...form, description: v })} />
|
||||||
|
|||||||
22
supabase/config.toml
Normal file
22
supabase/config.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
project_id = "lovable"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
enabled = true
|
||||||
|
port = 54321
|
||||||
|
schemas = ["public", "graphql_public"]
|
||||||
|
extra_search_path = ["public", "extensions"]
|
||||||
|
max_rows = 1000
|
||||||
|
|
||||||
|
[db]
|
||||||
|
port = 54322
|
||||||
|
major_version = 15
|
||||||
|
|
||||||
|
[studio]
|
||||||
|
enabled = true
|
||||||
|
port = 54323
|
||||||
|
|
||||||
|
[functions.pakasir-webhook]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.send-test-email]
|
||||||
|
verify_jwt = false
|
||||||
179
supabase/functions/send-test-email/index.ts
Normal file
179
supabase/functions/send-test-email/index.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user