feat: complete i18n support, telegram webhook, and security improvements

This commit is contained in:
AyrisAI
2026-05-14 13:46:17 +03:00
parent 4c9a07e3ef
commit cc65a2bd72
23 changed files with 798 additions and 205 deletions

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useTransition } from "react";
import { formatBytes } from "@/lib/format";
import { useDictionary } from "@/components/DictionaryContext";
interface Domain {
domain_name: string;
@@ -23,6 +24,7 @@ export default function DomainsPage() {
const [search, setSearch] = useState("");
const [form, setForm] = useState({ domain: "", description: "", mailboxes: "10", quota: "10240", maxquota: "10240" });
const [formError, setFormError] = useState("");
const dict = useDictionary();
const fetchDomains = async () => {
setLoading(true);
@@ -54,14 +56,23 @@ export default function DomainsPage() {
fetchDomains();
} else {
const data = await res.json();
const msg = Array.isArray(data) ? data.map((d: { msg?: string }) => d.msg).join(", ") : (data?.error ?? "Bir hata oluştu");
setFormError(String(msg));
let msg = "Bir hata oluştu";
if (Array.isArray(data)) {
msg = data.map((d: any) => {
if (typeof d.msg === "string") return d.msg;
if (Array.isArray(d.msg)) return d.msg.join(", ");
return JSON.stringify(d.msg || d);
}).join(" | ");
} else if (data?.error) {
msg = data.error;
}
setFormError(msg);
}
});
};
const handleDelete = (domain: string) => {
if (!confirm(`"${domain}" domainini Mailcow'dan silmek istediğinizden emin misiniz?\n\nBu işlem geri alınamaz!`)) return;
if (!confirm(`"${domain}" domainini silmek istediğinizden emin misiniz?\n\nBu işlem geri alınamaz!`)) return;
startTransition(async () => {
await fetch(`/api/domains/${encodeURIComponent(domain)}`, { method: "DELETE" });
fetchDomains();
@@ -78,11 +89,11 @@ export default function DomainsPage() {
<>
<div className="page-header">
<div>
<h1 className="page-title">Domainler</h1>
<p className="page-subtitle">Mailcow üzerindeki tüm domainleri yönetin</p>
<h1 className="page-title">{dict.domains.title || "Domainler"}</h1>
<p className="page-subtitle">{domains.length} {dict.domains.subtitle || "domain listeleniyor"}</p>
</div>
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
<PlusIcon /> Domain Ekle
<PlusIcon /> {dict.domains.addDomain || "Domain Ekle"}
</button>
</div>
@@ -93,13 +104,13 @@ export default function DomainsPage() {
<input
type="text"
className="input search-input"
placeholder="Domain veya açıklama ara..."
placeholder={dict.domains.searchPlaceholder || "Domain ara..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<button className="btn btn-ghost" onClick={fetchDomains}>
<RefreshIcon /> Yenile
<RefreshIcon /> {dict.domains.refresh || "Yenile"}
</button>
</div>
@@ -109,19 +120,19 @@ export default function DomainsPage() {
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><GlobeIcon size={24} /></div>
<div style={{ fontWeight: 600 }}>Domain bulunamadı</div>
<div style={{ fontSize: 12 }}>Mailcow&apos;a domain eklemek için &quot;Domain Ekle&quot; butonuna tıklayın.</div>
<div style={{ fontWeight: 600 }}>{dict.domains.noDomains || "Domain bulunamadı"}</div>
<div style={{ fontSize: 12 }}>{dict.domains.tryDiffSearch || "Farklı bir arama yapın."}</div>
</div>
) : (
<table>
<thead>
<tr>
<th>Domain</th>
<th>Mail Kutuları</th>
<th>Alias</th>
<th>Kota</th>
<th>Durum</th>
<th>İşlemler</th>
<th>{dict.domains.domain || "Domain"}</th>
<th>{dict.domains.mailboxes || "Mail Kutuları"}</th>
<th>{dict.domains.aliases || "Alias"}</th>
<th>{dict.domains.quota || "Kota"}</th>
<th>{dict.domains.status || "Durum"}</th>
<th>{dict.domains.actions || "İşlemler"}</th>
</tr>
</thead>
<tbody>
@@ -160,7 +171,7 @@ export default function DomainsPage() {
</td>
<td>
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
{String(d.active) === "1" ? `${dict.domains.active || "Aktif"}` : `${dict.domains.inactive || "Pasif"}`}
</span>
</td>
<td>
@@ -181,20 +192,20 @@ export default function DomainsPage() {
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowModal(false)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Mailcow&apos;a Domain Ekle</h2>
<h2 className="modal-title">{dict.domains.addDomain || "Domain Ekle"}</h2>
<button className="modal-close" onClick={() => setShowModal(false)}><XIcon /></button>
</div>
<form onSubmit={handleCreate}>
<div className="modal-body form-group">
{formError && <div className="error-msg">{formError}</div>}
<div>
<label className="label">Domain Adı</label>
<label className="label">{dict.domains.domain || "Domain Adı"}</label>
<input type="text" className="input" placeholder="ornek.com" value={form.domain}
onChange={(e) => setForm({ ...form, domain: e.target.value })} required />
</div>
<div>
<label className="label">ıklama (isteğe bağlı)</label>
<input type="text" className="input" placeholder="Bu domain hakkında..." value={form.description}
<label className="label">{dict.domains.description || "Açıklama"}</label>
<input type="text" className="input" placeholder="" value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="form-row">

View File

@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import Providers from "@/components/Providers";
import Sidebar from "@/components/Sidebar";
import { getDictionary, Locale } from "@/app/dictionaries";
import { DictionaryProvider } from "@/components/DictionaryContext";
export default async function DashboardLayout(
props: {
@@ -23,10 +24,12 @@ export default async function DashboardLayout(
return (
<Providers>
<DictionaryProvider dictionary={dict}>
<div className="app-layout">
<Sidebar dict={dict.sidebar} lang={params.lang} />
<Sidebar dict={dict} lang={params.lang} />
<div className="main-content">{children}</div>
</div>
</DictionaryProvider>
</Providers>
);
}

View File

@@ -1,11 +1,13 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import MailLogin from "@/components/mail/MailLogin";
import FolderList from "@/components/mail/FolderList";
import MessageList from "@/components/mail/MessageList";
import MessageView from "@/components/mail/MessageView";
import ComposeModal from "@/components/mail/ComposeModal";
import { useDictionary } from "@/components/DictionaryContext";
export interface MailFolder {
name: string;
@@ -34,6 +36,8 @@ export interface MailMessage extends MailEnvelope {
}
export default function MailPage() {
const params = useParams();
const lang = params.lang as string;
const [connected, setConnected] = useState<boolean | null>(null);
const [email, setEmail] = useState("");
const [folders, setFolders] = useState<MailFolder[]>([]);
@@ -44,6 +48,20 @@ export default function MailPage() {
const [loading, setLoading] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const [replyTo, setReplyTo] = useState<MailMessage | null>(null);
const dict = useDictionary();
const getFolderLabel = (path: string): string => {
const folder = folders.find((f) => f.path === path);
const name = folder?.name || path;
const lower = name.toLowerCase();
if (lower === "inbox") return dict.mailClient.inbox || "Inbox";
if (lower === "sent") return dict.mailClient.sent || "Sent";
if (lower === "drafts") return dict.mailClient.drafts || "Drafts";
if (lower === "trash") return dict.mailClient.trash || "Trash";
if (lower === "junk" || lower === "spam") return dict.mailClient.junk || "Junk";
if (lower === "archive") return dict.mailClient.archive || "Archive";
return name;
};
// Check connection
useEffect(() => {
@@ -130,7 +148,7 @@ export default function MailPage() {
<div className="mail-layout">
<div className="mail-sidebar">
<button className="btn btn-primary" style={{ width: "100%" }} onClick={() => { setReplyTo(null); setShowCompose(true); }}>
<ComposeIcon /> Yeni Mail
<ComposeIcon /> {dict.mailClient.newMail || "Yeni Mail"}
</button>
<FolderList
folders={folders}
@@ -140,7 +158,7 @@ export default function MailPage() {
<div className="mail-account">
<div className="mail-account-email">{email}</div>
<button className="btn btn-ghost btn-sm" onClick={handleDisconnect} style={{ fontSize: 11 }}>
Çıkış
{dict.mailClient.logout || ıkış"}
</button>
</div>
</div>
@@ -148,7 +166,7 @@ export default function MailPage() {
<div className="mail-list">
<div className="mail-list-header">
<h2>
{folders.find((f) => f.path === activeFolder)?.name ?? activeFolder}
{getFolderLabel(activeFolder)}
</h2>
<button className="btn btn-ghost btn-sm" onClick={() => loadMessages(activeFolder)}></button>
</div>
@@ -174,8 +192,8 @@ export default function MailPage() {
<div className="mail-empty-icon">
<MailBigIcon />
</div>
<div style={{ fontWeight: 600, fontSize: 14, color: "var(--text-secondary)" }}>Bir mail seçin</div>
<div style={{ fontSize: 12 }}>Okumak için soldaki listeden bir mail seçin</div>
<div style={{ fontWeight: 600, fontSize: 14, color: "var(--text-secondary)" }}>{dict.mailClient.selectMail || "Bir mail seçin"}</div>
<div style={{ fontSize: 12 }}>{dict.mailClient.selectMailDesc || "Okumak için soldaki listeden bir mail seçin"}</div>
</div>
)}
</div>

View File

@@ -3,6 +3,7 @@
import { useState, useEffect, useTransition, useCallback } from "react";
import { useSession } from "next-auth/react";
import { formatBytes } from "@/lib/format";
import { useDictionary } from "@/components/DictionaryContext";
interface Mailbox {
username: string;
@@ -32,6 +33,7 @@ export default function MailboxesPage() {
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072 });
const [newPassword, setNewPassword] = useState("");
const [formError, setFormError] = useState("");
const dict = useDictionary();
useEffect(() => {
fetch("/api/domains")
@@ -81,7 +83,7 @@ export default function MailboxesPage() {
};
const handleDelete = (username: string) => {
if (!confirm(`"${username}" hesabını silmek istediğinizden emin misiniz?`)) return;
if (!confirm(`"${username}" ${dict.mailboxes.deleteConfirm || "hesabını silmek istediğinizden emin misiniz?"}`)) return;
startTransition(async () => {
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, { method: "DELETE" });
setMailboxes((prev) => prev.filter((m) => m.username !== username));
@@ -128,11 +130,11 @@ export default function MailboxesPage() {
<>
<div className="page-header">
<div>
<h1 className="page-title">Mail Hesapları</h1>
<h1 className="page-title">{dict.mailboxes.title || "Mail Hesapları"}</h1>
<p className="page-subtitle">
{selectedDomain
? `${selectedDomain}${mailboxes.length} hesap`
: "Domain seçin"}
? `${selectedDomain}${mailboxes.length} ${dict.mailboxes.accounts || "hesap"}`
: (dict.mailboxes.selectDomain || "Domain seçin")}
</p>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
@@ -160,7 +162,7 @@ export default function MailboxesPage() {
onClick={() => setShowCreateModal(true)}
disabled={!selectedDomain}
>
<PlusIcon /> Hesap Ekle
<PlusIcon /> {dict.mailboxes.addAccount || "Hesap Ekle"}
</button>
</div>
</div>
@@ -172,7 +174,7 @@ export default function MailboxesPage() {
<input
type="text"
className="input search-input"
placeholder="E-posta veya isim ara..."
placeholder={dict.mailboxes.searchPlaceholder || "E-posta veya isim ara..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -188,21 +190,21 @@ export default function MailboxesPage() {
<div className="empty-state">
<div className="empty-icon"><MailIcon size={24} /></div>
<div style={{ fontWeight: 600 }}>
{selectedDomain ? "Bu domainde mail hesabı yok" : "Domain seçin"}
{selectedDomain ? (dict.mailboxes.noMailboxes || "Bu domainde mail hesabı yok") : (dict.mailboxes.selectDomain || "Domain seçin")}
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{selectedDomain ? '"Hesap Ekle" butonuna tıklayın' : "Sol üstteki listeden domain seçin"}
{selectedDomain ? (dict.mailboxes.noMailboxesDesc || "\"Hesap Ekle\" butonuna tıklayın") : (dict.mailboxes.selectDomainDesc || "Sol üstteki listeden domain seçin")}
</div>
</div>
) : (
<table>
<thead>
<tr>
<th>E-posta</th>
<th>Ad Soyad</th>
<th>Kota</th>
<th>Durum</th>
<th>İşlemler</th>
<th>{dict.mailboxes.email || "E-posta"}</th>
<th>{dict.mailboxes.name || "Ad Soyad"}</th>
<th>{dict.mailboxes.quota || "Kota"}</th>
<th>{dict.mailboxes.status || "Durum"}</th>
<th>{dict.mailboxes.actions || "İşlemler"}</th>
</tr>
</thead>
<tbody>
@@ -237,7 +239,7 @@ export default function MailboxesPage() {
</td>
<td>
<span className={`badge ${String(m.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(m.active) === "1" ? "● Aktif" : "● Pasif"}
{String(m.active) === "1" ? `${dict.mailboxes.active || "Aktif"}` : `${dict.mailboxes.inactive || "Pasif"}`}
</span>
</td>
<td>
@@ -245,28 +247,28 @@ export default function MailboxesPage() {
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowInfoModal(m.username)}
title="Bağlantı Bilgileri"
title={dict.mailboxes.info || "Bağlantı Bilgileri"}
>
<InfoIcon />
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowPasswordModal(m.username)}
title="Şifre Değiştir"
title={dict.mailboxes.changePassword || "Şifre Değiştir"}
>
<KeyIcon />
</button>
<button
className={`btn btn-sm ${String(m.active) === "1" ? "btn-ghost" : "btn-success"}`}
onClick={() => handleToggle(m.username, m.active)}
title={String(m.active) === "1" ? "Pasife Al" : "Aktif Et"}
title={String(m.active) === "1" ? (dict.mailboxes.deactivate || "Pasife Al") : (dict.mailboxes.activate || "Aktif Et")}
>
{String(m.active) === "1" ? <PauseIcon /> : <PlayIcon />}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(m.username)}
title="Sil"
title={dict.mailboxes.delete || "Sil"}
>
<TrashIcon />
</button>
@@ -286,14 +288,14 @@ export default function MailboxesPage() {
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowCreateModal(false)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Yeni Mail Hesabı</h2>
<h2 className="modal-title">{dict.mailboxes.newAccount || "Yeni Mail Hesabı"}</h2>
<button className="modal-close" onClick={() => setShowCreateModal(false)}><XIcon /></button>
</div>
<form onSubmit={handleCreate}>
<div className="modal-body form-group">
{formError && <div className="error-msg">{formError}</div>}
<div>
<label className="label">Kullanıcı Adı</label>
<label className="label">{dict.mailboxes.username || "Kullanıcı Adı"}</label>
<div style={{ display: "flex" }}>
<input
type="text" className="input"
@@ -313,29 +315,29 @@ export default function MailboxesPage() {
</div>
</div>
<div>
<label className="label">Ad Soyad</label>
<label className="label">{dict.mailboxes.name || "Ad Soyad"}</label>
<input type="text" className="input" placeholder="Emina Karabudak"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
required />
</div>
<div>
<label className="label">Şifre</label>
<input type="password" className="input" placeholder="Güçlü bir şifre"
<label className="label">{dict.mailboxes.password || "Şifre"}</label>
<input type="password" className="input" placeholder="********"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
required />
</div>
<div>
<label className="label">Kota (MB)</label>
<label className="label">{dict.mailboxes.quotaMb || "Kota (MB)"}</label>
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>İptal</button>
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>{dict.mailboxes.cancel || "İptal"}</button>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? <span className="spinner" /> : <PlusIcon />} Oluştur
{isPending ? <span className="spinner" /> : <PlusIcon />} {dict.mailboxes.create || "Oluştur"}
</button>
</div>
</form>
@@ -348,26 +350,26 @@ export default function MailboxesPage() {
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowPasswordModal(null)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Şifre Değiştir</h2>
<h2 className="modal-title">{dict.mailboxes.changePassword || ifre Değiştir"}</h2>
<button className="modal-close" onClick={() => setShowPasswordModal(null)}><XIcon /></button>
</div>
<form onSubmit={handlePasswordChange}>
<div className="modal-body form-group">
<div style={{ fontSize: 13, color: "var(--text-secondary)" }}>
<strong style={{ color: "var(--text-primary)" }}>{showPasswordModal}</strong> için yeni şifre
<strong style={{ color: "var(--text-primary)" }}>{showPasswordModal}</strong> {dict.mailboxes.newPasswordFor || "için yeni şifre"}
</div>
<div>
<label className="label">Yeni Şifre</label>
<input type="password" className="input" placeholder="Yeni şifre"
<label className="label">{dict.mailboxes.password || "Yeni Şifre"}</label>
<input type="password" className="input" placeholder="********"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required autoFocus />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowPasswordModal(null)}>İptal</button>
<button type="button" className="btn btn-ghost" onClick={() => setShowPasswordModal(null)}>{dict.mailboxes.cancel || "İptal"}</button>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? <span className="spinner" /> : <KeyIcon />} Güncelle
{isPending ? <span className="spinner" /> : <KeyIcon />} {dict.mailboxes.update || "Güncelle"}
</button>
</div>
</form>
@@ -379,49 +381,49 @@ export default function MailboxesPage() {
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowInfoModal(null)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">İstemci Bağlantı Bilgileri</h2>
<h2 className="modal-title">{dict.mailboxes.connectionInfo || stemci Bağlantı Bilgileri"}</h2>
<button className="modal-close" onClick={() => setShowInfoModal(null)}><XIcon /></button>
</div>
<div className="modal-body form-group">
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 10 }}>
<strong>{showInfoModal}</strong> hesabını Apple Mail, Outlook veya telefonunuza kurmak için aşağıdaki bilgileri kullanın:
<strong>{showInfoModal}</strong> {dict.mailboxes.connectionInfoDesc || "hesabını kurmak için aşağıdaki bilgileri kullanın:"}
</div>
<div style={{ background: "var(--bg-hover)", padding: 12, borderRadius: "var(--radius)", border: "1px solid var(--border)", fontSize: 13 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>IMAP (Gelen Sunucu)</div>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>{dict.mailboxes.imap || "IMAP (Gelen Sunucu)"}</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<span>{dict.mailboxes.server || "Sunucu"}: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> {dict.mailboxes.copy || "Kopyala"}</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>993</strong> (SSL/TLS)</div>
<div style={{ marginTop: 2 }}>{dict.mailboxes.port || "Port"}: <strong>993</strong> (SSL/TLS)</div>
</div>
<div style={{ marginBottom: 12, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>SMTP (Giden Sunucu)</div>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>{dict.mailboxes.smtp || "SMTP (Giden Sunucu)"}</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<span>{dict.mailboxes.server || "Sunucu"}: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> {dict.mailboxes.copy || "Kopyala"}</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>587</strong> (STARTTLS) <span style={{ color: "var(--text-secondary)" }}>veya 465 (SSL)</span></div>
<div style={{ marginTop: 2 }}>{dict.mailboxes.port || "Port"}: <strong>587</strong> (STARTTLS) <span style={{ color: "var(--text-secondary)" }}>veya 465 (SSL)</span></div>
</div>
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>Kimlik Doğrulama</div>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>{dict.mailboxes.auth || "Kimlik Doğrulama"}</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Kullanıcı Adı: <strong>{showInfoModal}</strong></span>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText(showInfoModal)} title="Kopyala"><CopyIcon /> Kopyala</button>
<span>{dict.mailboxes.username || "Kullanıcı Adı"}: <strong>{showInfoModal}</strong></span>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText(showInfoModal)} title="Kopyala"><CopyIcon /> {dict.mailboxes.copy || "Kopyala"}</button>
</div>
<div style={{ color: "var(--text-secondary)", marginTop: 4 }}>Şifre: <span style={{ fontStyle: "italic" }}>Hesap oluştururken belirlediğiniz şifre</span></div>
<div style={{ color: "var(--text-secondary)", marginTop: 4 }}>{dict.mailboxes.password || "Şifre"}: <span style={{ fontStyle: "italic" }}>{dict.mailboxes.authPassword || "Hesap oluştururken belirlediğiniz şifre"}</span></div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-primary" onClick={() => setShowInfoModal(null)}>Tamam</button>
<button type="button" className="btn btn-primary" onClick={() => setShowInfoModal(null)}>{dict.mailboxes.ok || "Tamam"}</button>
</div>
</div>
</div>

View File

@@ -2,11 +2,20 @@ import { auth } from "@/auth";
import { getDomains } from "@/lib/mailcow";
import { canAccessDomain } from "@/lib/users";
import { formatBytes } from "@/lib/format";
import { getDictionary, Locale } from "@/app/dictionaries";
export default async function DashboardPage(
props: {
params: Promise<{ lang: string }>;
}
) {
const params = await props.params;
export default async function DashboardPage() {
const session = await auth();
const role = session?.user?.role;
const userDomains = session?.user?.domains ?? [];
const lang = params.lang as Locale;
const dict = await getDictionary(lang);
const allDomains = await getDomains();
const visibleDomains = allDomains.filter((d) => canAccessDomain(userDomains, d.domain_name));
@@ -18,36 +27,36 @@ export default async function DashboardPage() {
<>
<div className="page-header">
<div>
<h1 className="page-title">Dashboard</h1>
<p className="page-subtitle">Hoş geldiniz, {session?.user?.name} 👋</p>
<h1 className="page-title">{dict.dashboard.title || "Dashboard"}</h1>
<p className="page-subtitle">{dict.dashboard.welcome || "Hoş geldiniz"}, {session?.user?.name} 👋</p>
</div>
</div>
<div className="page-body">
<div className="stats-grid">
<StatCard
label="Toplam Domain"
label={dict.dashboard.totalDomains || "Toplam Domain"}
value={visibleDomains.length}
color="var(--accent)"
icon={<GlobeIcon />}
/>
<StatCard
label="Mail Kutuları"
label={dict.dashboard.mailboxes || "Mail Kutuları"}
value={totalMailboxes}
color="var(--success)"
icon={<MailIcon />}
/>
<StatCard
label="Alias"
label={dict.dashboard.aliases || "Alias"}
value={totalAliases}
color="var(--warning)"
icon={<AtIcon />}
/>
{role === "SUPER_ADMIN" && (
<StatCard
label="Tanımlı Kullanıcı"
label={dict.dashboard.users || "Tanımlı Kullanıcı"}
value={"—"}
sub="Kullanıcılar .env'den yönetilir"
sub={dict.dashboard.usersSub || "Kullanıcılar .env'den yönetilir"}
color="var(--text-muted)"
icon={<UsersIcon />}
/>
@@ -59,17 +68,17 @@ export default async function DashboardPage() {
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<div style={{ padding: "16px 20px", borderBottom: "1px solid var(--border)" }}>
<h2 style={{ fontSize: 14, fontWeight: 700, color: "var(--text-primary)" }}>
Domain Durumu
{dict.dashboard.domainStatus || "Domain Durumu"}
</h2>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Domain</th>
<th>Mail Kutuları</th>
<th>Kota Kullanımı</th>
<th>Durum</th>
<th>{dict.dashboard.domain || "Domain"}</th>
<th>{dict.dashboard.mailboxes || "Mail Kutuları"}</th>
<th>{dict.dashboard.quotaUsage || "Kota Kullanımı"}</th>
<th>{dict.dashboard.status || "Durum"}</th>
</tr>
</thead>
<tbody>
@@ -104,7 +113,7 @@ export default async function DashboardPage() {
</td>
<td>
<span className={`badge ${String(d.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(d.active) === "1" ? "● Aktif" : "● Pasif"}
{String(d.active) === "1" ? `${dict.dashboard.active || "Aktif"}` : `${dict.dashboard.inactive || "Pasif"}`}
</span>
</td>
</tr>
@@ -119,31 +128,31 @@ export default async function DashboardPage() {
{/* Quick actions */}
<div className="card">
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 16, color: "var(--text-primary)" }}>
Hızlı İşlemler
{dict.dashboard.quickActions || "Hızlı İşlemler"}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{role === "SUPER_ADMIN" && (
<QuickItem
href="/dashboard/domains"
href={`/${lang}/dashboard/domains`}
icon={<GlobeIcon />}
title="Domain Yönetimi"
desc="Domain ekle, sil, yönet"
title={dict.dashboard.manageDomains || "Domain Yönetimi"}
desc={dict.dashboard.manageDomainsDesc || "Domain ekle, sil, yönet"}
color="var(--accent)"
/>
)}
<QuickItem
href="/dashboard/mailboxes"
href={`/${lang}/dashboard/mailboxes`}
icon={<MailIcon />}
title="Mail Hesapları"
desc="Yeni hesap oluştur, şifre değiştir, sil"
title={dict.dashboard.manageMailboxes || "Mail Hesapları"}
desc={dict.dashboard.manageMailboxesDesc || "Yeni hesap oluştur, şifre değiştir, sil"}
color="var(--success)"
/>
{role === "SUPER_ADMIN" && (
<QuickItem
href="/dashboard/users"
href={`/${lang}/dashboard/users`}
icon={<UsersIcon />}
title="Kullanıcılar"
desc=".env'den tanımlı panel kullanıcılarını görüntüle"
title={dict.dashboard.manageUsers || "Kullanıcılar"}
desc={dict.dashboard.manageUsersDesc || ".env'den tanımlı panel kullanıcılarını görüntüle"}
color="var(--warning)"
/>
)}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useDictionary } from "@/components/DictionaryContext";
interface User {
id: string;
@@ -14,6 +15,7 @@ export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const dict = useDictionary();
useEffect(() => {
fetch("/api/users")
@@ -35,8 +37,8 @@ export default function UsersPage() {
<>
<div className="page-header">
<div>
<h1 className="page-title">Kullanıcılar</h1>
<p className="page-subtitle">Panel kullanıcıları .env dosyasından yönetilir</p>
<h1 className="page-title">{dict.users.title || "Kullanıcılar"}</h1>
<p className="page-subtitle">{dict.users.subtitle || "Panel kullanıcıları .env dosyasından yönetilir"}</p>
</div>
</div>
@@ -47,12 +49,16 @@ export default function UsersPage() {
<InfoIcon />
</div>
<div>
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>Kullanıcı yönetimi hakkında</div>
<div style={{ fontWeight: 600, color: "var(--accent-hover)", marginBottom: 6 }}>{dict.users.info ? "Info" : "Kullanıcı yönetimi hakkında"}</div>
<div style={{ fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.7 }}>
{dict.users.info || (
<>
Kullanıcılar <code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>.env</code> dosyasındaki{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_0_*</code>,{" "}
<code style={{ background: "var(--bg)", padding: "1px 6px", borderRadius: 4, fontSize: 12 }}>USER_1_*</code> değişkenleriyle tanımlanır.
Yeni kullanıcı eklemek için .env dosyasını düzenleyip uygulamayı yeniden başlatın.
</>
)}
</div>
<div style={{ marginTop: 12, padding: "10px 14px", background: "var(--bg)", borderRadius: "var(--radius)", fontSize: 12, fontFamily: "monospace", color: "var(--text-secondary)", lineHeight: 2 }}>
USER_2_NAME=&quot;Ahmet Yılmaz&quot;<br />
@@ -70,7 +76,7 @@ export default function UsersPage() {
<input
type="text"
className="input search-input"
placeholder="İsim veya e-posta ara..."
placeholder={dict.users.searchPlaceholder || "İsim veya e-posta ara..."}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@@ -85,15 +91,15 @@ export default function UsersPage() {
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><UsersIcon /></div>
<div style={{ fontWeight: 600 }}>Kullanıcı bulunamadı</div>
<div style={{ fontWeight: 600 }}>{dict.users.noUsers || "Kullanıcı bulunamadı"}</div>
</div>
) : (
<table>
<thead>
<tr>
<th>Kullanıcı</th>
<th>Rol</th>
<th>İzin Verilen Domainler</th>
<th>{dict.users.username || "Kullanıcı"}</th>
<th>{dict.users.role || "Rol"}</th>
<th>{dict.users.domains || "İzin Verilen Domainler"}</th>
</tr>
</thead>
<tbody>
@@ -112,12 +118,12 @@ export default function UsersPage() {
</td>
<td>
<span className={`badge ${u.role === "SUPER_ADMIN" ? "badge-blue" : "badge-green"}`}>
{u.role === "SUPER_ADMIN" ? "★ Süper Admin" : "Domain Admin"}
{u.role === "SUPER_ADMIN" ? `${dict.users.superAdmin || "Süper Admin"}` : (dict.users.domainAdmin || "Domain Admin")}
</span>
</td>
<td>
{u.domains.includes("*") ? (
<span className="badge badge-blue">Tüm domainler</span>
<span className="badge badge-blue">{dict.users.allDomains || "Tüm domainler"}</span>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{u.domains.map((d) => (

View File

@@ -23,7 +23,7 @@ export default function LoginForm({ dict, lang }: { dict: any; lang: string }) {
});
if (res?.error) {
setError("E-posta veya şifre hatalı."); // We can translate this later
setError(dict.errorInvalid || "E-posta veya şifre hatalı.");
} else {
router.push(`/${lang}/dashboard`);
router.refresh();

View File

@@ -30,5 +30,13 @@ export async function POST(req: NextRequest) {
if (!domain) return NextResponse.json({ error: "domain gerekli" }, { status: 400 });
const result = await createDomain({ domain, description, mailboxes, quota, maxquota });
if (result.ok && Array.isArray(result.data)) {
const hasError = result.data.some((item: any) => item.type === "error");
if (hasError) {
return NextResponse.json(result.data, { status: 400 });
}
}
return NextResponse.json(result.data, { status: result.ok ? 200 : 502 });
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { getUsers } from "@/lib/users";
/**
* app/api/webhooks/mail/route.ts
*
* Webhook endpoint for incoming mail notifications (e.g. from Rspamd or Mailcow).
* Sends notifications to Telegram based on the recipient email.
*/
export async function POST(req: NextRequest) {
try {
const data = await req.json();
// Extract basic info from the incoming payload
const recipient = (data.to || data.rcpt || "").toLowerCase();
const sender = data.from || "Bilinmiyor";
const subject = data.subject || "(Konu Yok)";
console.log(`[Mail Webhook] Yeni mail geldi: ${sender} -> ${recipient}`);
const users = getUsers();
const user = users.find(u => u.email.toLowerCase() === recipient);
const targetChatId = user?.telegramId;
if (targetChatId && process.env.TELEGRAM_BOT_TOKEN) {
const message = `🔔 *Yeni Mail!*\n\n*Kimden:* ${sender}\n*Konu:* ${subject}`;
const telegramUrl = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}/sendMessage`;
const res = await fetch(telegramUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: targetChatId,
text: message,
parse_mode: "Markdown",
}),
});
if (!res.ok) {
const errorText = await res.text();
console.error(`[Mail Webhook] Telegram API hatası: ${res.status} ${errorText}`);
} else {
console.log(`[Mail Webhook] Telegram bildirimi gönderildi: ${recipient}`);
}
} else {
if (!targetChatId) {
console.log(`[Mail Webhook] Alıcı için eşleşen Telegram ID bulunamadı: ${recipient}`);
}
if (!process.env.TELEGRAM_BOT_TOKEN) {
console.error("[Mail Webhook] TELEGRAM_BOT_TOKEN ayarlı değil!");
}
}
return NextResponse.json({ status: "ok" });
} catch (error: any) {
console.error(`[Mail Webhook] Hata: ${error.message}`);
return NextResponse.json({ error: "İşlem başarısız" }, { status: 500 });
}
}

View File

@@ -7,12 +7,148 @@
"passwordLabel": "Password",
"passwordPlaceholder": "Enter your password",
"signInButton": "Sign In",
"signingIn": "Signing In..."
"signingIn": "Signing In...",
"errorInvalid": "Invalid email or password."
},
"sidebar": {
"mailboxes": "Mailboxes",
"mailClient": "Mail Client",
"users": "User Management",
"logout": "Log Out"
"logout": "Log Out",
"general": "GENERAL",
"management": "MANAGEMENT",
"superAdmin": "Super Admin",
"domainAdmin": "Domain Admin"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome",
"totalDomains": "Total Domains",
"mailboxes": "Mailboxes",
"aliases": "Aliases",
"users": "Defined Users",
"usersSub": "Users are managed from .env",
"domainStatus": "Domain Status",
"domain": "Domain",
"quotaUsage": "Quota Usage",
"status": "Status",
"active": "Active",
"inactive": "Inactive",
"quickActions": "Quick Actions",
"manageDomains": "Domain Management",
"manageDomainsDesc": "Add, delete, manage domains",
"manageMailboxes": "Mailboxes",
"manageMailboxesDesc": "Create new account, change password, delete",
"manageUsers": "Users",
"manageUsersDesc": "View defined panel users from .env"
},
"domains": {
"title": "Domains",
"subtitle": "domain listed",
"searchPlaceholder": "Search domain...",
"domain": "Domain",
"description": "Description",
"mailboxes": "Mailboxes",
"aliases": "Aliases",
"quota": "Quota",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"addDomain": "Add Domain",
"refresh": "Refresh",
"noDomains": "No domains found",
"tryDiffSearch": "Try a different search term"
},
"users": {
"title": "System Users",
"subtitle": "Admin users defined via .env file",
"info": "User management is done securely via the environment variables (.env). This panel is read-only.",
"username": "Username",
"name": "Name",
"role": "Role",
"domains": "Authorized Domains",
"superAdmin": "Super Admin",
"domainAdmin": "Domain Admin",
"allDomains": "All Domains"
},
"mailboxes": {
"title": "Mailboxes",
"selectDomain": "Select domain",
"accounts": "accounts",
"addAccount": "Add Account",
"searchPlaceholder": "Search email or name...",
"email": "Email",
"name": "Name",
"quota": "Quota",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"noMailboxes": "No mailboxes in this domain",
"noMailboxesDesc": "Click 'Add Account'",
"selectDomainDesc": "Select a domain from the top left",
"deleteConfirm": "Are you sure you want to delete this account?",
"info": "Connection Info",
"changePassword": "Change Password",
"deactivate": "Deactivate",
"activate": "Activate",
"delete": "Delete",
"newAccount": "New Mailbox",
"username": "Username",
"password": "Password",
"quotaMb": "Quota (MB)",
"cancel": "Cancel",
"create": "Create",
"newPasswordFor": "New password for",
"update": "Update",
"connectionInfo": "Client Connection Info",
"connectionInfoDesc": "Use the following details to set up the account on Apple Mail, Outlook or your phone:",
"imap": "IMAP (Incoming Server)",
"server": "Server",
"port": "Port",
"copy": "Copy",
"smtp": "SMTP (Outgoing Server)",
"auth": "Authentication",
"authPassword": "The password you set when creating the account",
"ok": "OK"
},
"mailClient": {
"loginTitle": "IMAP Login",
"loginSubtitle": "Connect to your mailbox",
"emailLabel": "Email",
"emailPlaceholder": "name@domain.com",
"passwordLabel": "Password",
"passwordPlaceholder": "********",
"connect": "Connect",
"connecting": "Connecting...",
"newMail": "New Mail",
"logout": "Logout",
"selectMail": "Select a mail",
"selectMailDesc": "Select a mail from the left list to read",
"inbox": "Inbox",
"sent": "Sent",
"drafts": "Drafts",
"trash": "Trash",
"junk": "Junk",
"archive": "Archive",
"searchMail": "Search mail...",
"emptyFolder": "This folder is empty",
"to": "To",
"cc": "Cc",
"subject": "Subject",
"date": "Date",
"attachments": "Attachments",
"reply": "Reply",
"delete": "Delete",
"download": "Download",
"composeTitle": "New Message",
"send": "Send",
"sending": "Sending...",
"cancel": "Cancel",
"dropFiles": "Drop files here or click to attach",
"sendError": "Failed to send message",
"noSubject": "(No Subject)",
"me": "Me"
}
}

View File

@@ -7,12 +7,148 @@
"passwordLabel": "Şifre",
"passwordPlaceholder": "Şifrenizi girin",
"signInButton": "Giriş Yap",
"signingIn": "Giriş yapılıyor..."
"signingIn": "Giriş yapılıyor...",
"errorInvalid": "E-posta veya şifre hatalı."
},
"sidebar": {
"mailboxes": "Mail Hesapları",
"mailClient": "Web Mail Client",
"mailClient": "Mail İstemcisi",
"users": "Kullanıcı Yönetimi",
"logout": ıkış Yap"
"logout": ıkış Yap",
"general": "GENEL",
"management": "YÖNETİM",
"superAdmin": "Süper Admin",
"domainAdmin": "Domain Admin"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Hoş geldiniz",
"totalDomains": "Toplam Domain",
"mailboxes": "Mail Kutuları",
"aliases": "Alias",
"users": "Tanımlı Kullanıcı",
"usersSub": "Kullanıcılar .env'den yönetilir",
"domainStatus": "Domain Durumu",
"domain": "Domain",
"quotaUsage": "Kota Kullanımı",
"status": "Durum",
"active": "Aktif",
"inactive": "Pasif",
"quickActions": "Hızlı İşlemler",
"manageDomains": "Domain Yönetimi",
"manageDomainsDesc": "Domain ekle, sil, yönet",
"manageMailboxes": "Mail Hesapları",
"manageMailboxesDesc": "Yeni hesap oluştur, şifre değiştir, sil",
"manageUsers": "Kullanıcılar",
"manageUsersDesc": ".env'den tanımlı panel kullanıcılarını görüntüle"
},
"domains": {
"title": "Domainler",
"subtitle": "domain listeleniyor",
"searchPlaceholder": "Domain ara...",
"domain": "Domain",
"description": "Açıklama",
"mailboxes": "Mail Kutuları",
"aliases": "Alias",
"quota": "Kota",
"status": "Durum",
"actions": "İşlemler",
"active": "Aktif",
"inactive": "Pasif",
"addDomain": "Domain Ekle",
"refresh": "Yenile",
"noDomains": "Domain bulunamadı",
"tryDiffSearch": "Farklı bir arama yapın"
},
"users": {
"title": "Sistem Kullanıcıları",
"subtitle": ".env dosyası üzerinden tanımlanmış yetkili kullanıcılar",
"info": "Kullanıcı yönetimi güvenlik nedeniyle sadece çevresel değişkenler (.env) üzerinden yapılmaktadır. Bu ekran salt okunurdur.",
"username": "Kullanıcı Adı",
"name": "Ad Soyad",
"role": "Rol",
"domains": "Yetkili Domainler",
"superAdmin": "Süper Admin",
"domainAdmin": "Domain Admin",
"allDomains": "Tüm Domainler"
},
"mailboxes": {
"title": "Mail Hesapları",
"selectDomain": "Domain seçin",
"accounts": "hesap",
"addAccount": "Hesap Ekle",
"searchPlaceholder": "E-posta veya isim ara...",
"email": "E-posta",
"name": "Ad Soyad",
"quota": "Kota",
"status": "Durum",
"actions": "İşlemler",
"active": "Aktif",
"inactive": "Pasif",
"noMailboxes": "Bu domainde mail hesabı yok",
"noMailboxesDesc": "'Hesap Ekle' butonuna tıklayın",
"selectDomainDesc": "Sol üstteki listeden domain seçin",
"deleteConfirm": "hesabını silmek istediğinizden emin misiniz?",
"info": "Bağlantı Bilgileri",
"changePassword": "Şifre Değiştir",
"deactivate": "Pasife Al",
"activate": "Aktif Et",
"delete": "Sil",
"newAccount": "Yeni Mail Hesabı",
"username": "Kullanıcı Adı",
"password": "Şifre",
"quotaMb": "Kota (MB)",
"cancel": "İptal",
"create": "Oluştur",
"newPasswordFor": "için yeni şifre",
"update": "Güncelle",
"connectionInfo": "İstemci Bağlantı Bilgileri",
"connectionInfoDesc": "hesabını Apple Mail, Outlook veya telefonunuza kurmak için aşağıdaki bilgileri kullanın:",
"imap": "IMAP (Gelen Sunucu)",
"server": "Sunucu",
"port": "Port",
"copy": "Kopyala",
"smtp": "SMTP (Giden Sunucu)",
"auth": "Kimlik Doğrulama",
"authPassword": "Hesap oluştururken belirlediğiniz şifre",
"ok": "Tamam"
},
"mailClient": {
"loginTitle": "IMAP Girişi",
"loginSubtitle": "Mail kutunuza bağlanın",
"emailLabel": "E-posta",
"emailPlaceholder": "isim@domain.com",
"passwordLabel": "Şifre",
"passwordPlaceholder": "********",
"connect": "Bağlan",
"connecting": "Bağlanıyor...",
"newMail": "Yeni Mail",
"logout": ıkış",
"selectMail": "Bir mail seçin",
"selectMailDesc": "Okumak için soldaki listeden bir mail seçin",
"inbox": "Gelen Kutusu",
"sent": "Gönderilmiş",
"drafts": "Taslaklar",
"trash": "Çöp Kutusu",
"junk": "Gereksiz",
"archive": "Arşiv",
"searchMail": "Mail ara...",
"emptyFolder": "Bu klasör boş",
"to": "Kime",
"cc": "Bilgi",
"subject": "Konu",
"date": "Tarih",
"attachments": "Ekler",
"reply": "Yanıtla",
"delete": "Sil",
"download": "İndir",
"composeTitle": "Yeni Mesaj",
"send": "Gönder",
"sending": "Gönderiliyor...",
"cancel": "İptal",
"dropFiles": "Dosyaları sürükleyin veya dosya eklemek için tıklayın",
"sendError": "Mesaj gönderilemedi",
"noSubject": "(Konu Yok)",
"me": "Ben"
}
}

View File

@@ -1105,3 +1105,18 @@ tr:hover td {
.page-header { padding: 16px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
}
/* ── Language Switcher ── */
.lang-switcher .btn-ghost {
border-radius: var(--radius) !important;
font-weight: 400 !important;
}
.lang-option:hover {
background: var(--bg-hover) !important;
color: var(--text-primary) !important;
}
.lang-option.active {
color: var(--accent-hover) !important;
font-weight: 600;
}

View File

@@ -0,0 +1,27 @@
"use client";
import React, { createContext, useContext } from "react";
const DictionaryContext = createContext<any>(null);
export function DictionaryProvider({
dictionary,
children,
}: {
dictionary: any;
children: React.ReactNode;
}) {
return (
<DictionaryContext.Provider value={dictionary}>
{children}
</DictionaryContext.Provider>
);
}
export function useDictionary() {
const context = useContext(DictionaryContext);
if (!context) {
throw new Error("useDictionary must be used within a DictionaryProvider");
}
return context;
}

View File

@@ -0,0 +1,101 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState, useRef, useEffect } from "react";
export default function LanguageSwitcher({ currentLang }: { currentLang: string }) {
const pathname = usePathname();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const languages = [
{ code: "tr", label: "Türkçe", flag: "🇹🇷" },
{ code: "en", label: "English", flag: "🇺🇸" },
];
const handleLangChange = (newLang: string) => {
if (newLang === currentLang) {
setIsOpen(false);
return;
}
const segments = pathname.split("/");
segments[1] = newLang;
const newPath = segments.join("/");
router.push(newPath);
setIsOpen(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const current = languages.find((l) => l.code === currentLang) || languages[0];
return (
<div className="lang-switcher" ref={dropdownRef} style={{ position: "relative", marginBottom: "12px" }}>
<button
className="btn btn-ghost"
onClick={() => setIsOpen(!isOpen)}
style={{ width: "100%", justifyContent: "space-between", padding: "8px 12px", border: "1px solid var(--border-color)" }}
>
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>{current.flag}</span>
<span style={{ fontSize: "13px" }}>{current.label}</span>
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: isOpen ? "rotate(180deg)" : "none", transition: "transform 0.2s" }}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{isOpen && (
<div style={{
position: "absolute",
bottom: "100%",
left: 0,
right: 0,
marginBottom: "4px",
background: "var(--card-bg)",
border: "1px solid var(--border-color)",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.2)",
zIndex: 100,
overflow: "hidden"
}}>
{languages.map((lang) => (
<button
key={lang.code}
className={`lang-option ${lang.code === currentLang ? "active" : ""}`}
onClick={() => handleLangChange(lang.code)}
style={{
width: "100%",
padding: "10px 12px",
display: "flex",
alignItems: "center",
gap: "10px",
background: lang.code === currentLang ? "var(--bg-secondary)" : "transparent",
border: "none",
color: "var(--text-primary)",
cursor: "pointer",
textAlign: "left",
fontSize: "13px",
transition: "background 0.2s"
}}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useSession, signOut } from "next-auth/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import LanguageSwitcher from "./LanguageSwitcher";
export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
const { data: session } = useSession();
@@ -13,18 +14,18 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
const navItems = [
{
section: "GENEL",
section: dict.sidebar?.general || "GENEL",
items: [
{ href: `/${lang}/dashboard`, label: "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/mail`, label: dict.mailClient || "Mail", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard`, label: dict.dashboard?.title || "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/mail`, label: dict.sidebar?.mailClient || "Mail Client", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
],
},
{
section: "YÖNETİM",
section: dict.sidebar?.management || "YÖNETİM",
items: [
{ href: `/${lang}/dashboard/domains`, label: "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/users`, label: dict.users || "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/mailboxes`, label: dict.mailboxes || "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
{ href: `/${lang}/dashboard/domains`, label: dict.domains?.title || "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/users`, label: dict.sidebar?.users || "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] },
{ href: `/${lang}/dashboard/mailboxes`, label: dict.sidebar?.mailboxes || "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] },
],
},
];
@@ -68,23 +69,26 @@ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
</nav>
<div className="sidebar-footer">
<LanguageSwitcher currentLang={lang} />
<div className="user-info" style={{ marginBottom: "12px" }}>
<div className="user-avatar">{name[0]?.toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="user-name" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name}</div>
<div className="user-role">{role === "SUPER_ADMIN" ? "Süper Admin" : "Domain Admin"}</div>
<div className="user-role">{role === "SUPER_ADMIN" ? (dict.superAdmin || "Süper Admin") : (dict.domainAdmin || "Domain Admin")}</div>
</div>
</div>
<button
className="btn btn-ghost"
style={{ width: "100%", justifyContent: "center", fontSize: "12px" }}
onClick={async () => {
await fetch("/api/mail/auth", { method: "DELETE" });
await signOut({ redirect: false });
window.location.href = `/${lang}/login`;
}}
>
<LogOutIcon />
{dict.logout || ıkış Yap"}
{dict.sidebar?.logout || ıkış Yap"}
</button>
</div>
</aside>

View File

@@ -2,6 +2,7 @@
import { useState, useRef, useCallback } from "react";
import type { MailMessage } from "@/app/[lang]/dashboard/mail/page";
import { formatBytes } from "@/lib/format";
import { useDictionary } from "@/components/DictionaryContext";
interface AttachmentFile {
file: File;
@@ -14,6 +15,7 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
onClose: () => void;
onSent: () => void;
}) {
const dict = useDictionary();
const [to, setTo] = useState(replyTo ? replyTo.from[0]?.address ?? "" : "");
const [cc, setCc] = useState("");
const [subject, setSubject] = useState(replyTo ? `Re: ${replyTo.subject.replace(/^Re:\s*/i, "")}` : "");
@@ -60,7 +62,7 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
const res = await fetch("/api/mail/send", { method: "POST", body: formData });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Gönderilemedi");
if (!res.ok) throw new Error(data.error || dict.mailClient.sendError || "Gönderilemedi");
} else {
// JSON for simple messages
const res = await fetch("/api/mail/send", {
@@ -72,7 +74,7 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Gönderilemedi");
if (!res.ok) throw new Error(data.error || dict.mailClient.sendError || "Gönderilemedi");
}
onSent();
} catch (err: any) {
@@ -87,24 +89,24 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 620 }}>
<div className="modal-header">
<h2 className="modal-title">{replyTo ? "Yanıtla" : "Yeni Mail"}</h2>
<h2 className="modal-title">{replyTo ? (dict.mailClient.reply || "Yanıtla") : (dict.mailClient.composeTitle || "Yeni Mail")}</h2>
<button className="modal-close" onClick={onClose}></button>
</div>
<form onSubmit={handleSend}>
<div className="modal-body form-group">
{error && <div className="error-msg">{error}</div>}
<div>
<label className="label">Alıcı</label>
<label className="label">{dict.mailClient.to || "Alıcı"}</label>
<input type="email" className="input" placeholder="alici@domain.com" value={to}
onChange={(e) => setTo(e.target.value)} required autoFocus />
</div>
<div>
<label className="label">CC (isteğe bağlı)</label>
<label className="label">{dict.mailClient.cc || "CC"}</label>
<input type="text" className="input" placeholder="cc@domain.com" value={cc}
onChange={(e) => setCc(e.target.value)} />
</div>
<div>
<label className="label">Konu</label>
<label className="label">{dict.mailClient.subject || "Konu"}</label>
<input type="text" className="input" value={subject}
onChange={(e) => setSubject(e.target.value)} required />
</div>
@@ -131,7 +133,7 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
onChange={(e) => { addFiles(e.target.files); e.target.value = ""; }}
/>
<span style={{ fontSize: 20 }}>📎</span>
<span>Dosya sürükleyin veya tıklayın</span>
<span>{dict.mailClient.dropFiles || "Dosya sürükleyin veya tıklayın"}</span>
</div>
{/* Attachment list */}
@@ -145,15 +147,15 @@ export default function ComposeModal({ replyTo, onClose, onSent }: {
</div>
))}
<div style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 4 }}>
Toplam: {formatBytes(totalSize)} {attachments.length} dosya
{formatBytes(totalSize)} {attachments.length} {dict.mailClient.attachments || "dosya"}
</div>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>İptal</button>
<button type="button" className="btn btn-ghost" onClick={onClose}>{dict.mailClient.cancel || "İptal"}</button>
<button type="submit" className="btn btn-primary" disabled={sending}>
{sending ? <span className="spinner" /> : <SendIcon />} Gönder
{sending ? <span className="spinner" /> : <SendIcon />} {dict.mailClient.send || "Gönder"}
</button>
</div>
</form>

View File

@@ -1,5 +1,6 @@
"use client";
import type { MailFolder } from "@/app/[lang]/dashboard/mail/page";
import { useDictionary } from "@/components/DictionaryContext";
const FOLDER_ICONS: Record<string, string> = {
"\\Inbox": "📥",
@@ -10,15 +11,6 @@ const FOLDER_ICONS: Record<string, string> = {
"\\Archive": "📦",
};
const FOLDER_LABELS: Record<string, string> = {
INBOX: "Gelen Kutusu",
Sent: "Gönderilenler",
Drafts: "Taslaklar",
Trash: "Çöp Kutusu",
Junk: "Spam",
Archive: "Arşiv",
};
function getFolderIcon(folder: MailFolder): string {
if (folder.specialUse && FOLDER_ICONS[folder.specialUse]) return FOLDER_ICONS[folder.specialUse];
const lower = folder.path.toLowerCase();
@@ -31,15 +23,25 @@ function getFolderIcon(folder: MailFolder): string {
return "📁";
}
function getFolderLabel(folder: MailFolder): string {
return FOLDER_LABELS[folder.name] ?? FOLDER_LABELS[folder.path] ?? folder.name;
}
export default function FolderList({ folders, active, onSelect }: {
folders: MailFolder[];
active: string;
onSelect: (path: string) => void;
}) {
const dict = useDictionary();
const getFolderLabel = (folder: MailFolder): string => {
const name = folder.name || folder.path;
const lower = name.toLowerCase();
if (lower === "inbox") return dict.mailClient.inbox || "Inbox";
if (lower === "sent") return dict.mailClient.sent || "Sent";
if (lower === "drafts") return dict.mailClient.drafts || "Drafts";
if (lower === "trash") return dict.mailClient.trash || "Trash";
if (lower === "junk" || lower === "spam") return dict.mailClient.junk || "Junk";
if (lower === "archive") return dict.mailClient.archive || "Archive";
return name;
};
const sorted = [...folders].sort((a, b) => {
if (a.path === "INBOX") return -1;
if (b.path === "INBOX") return 1;

View File

@@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { useDictionary } from "@/components/DictionaryContext";
export default function MailLogin({ onSuccess }: { onSuccess: (email: string) => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const dict = useDictionary();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -30,29 +32,26 @@ export default function MailLogin({ onSuccess }: { onSuccess: (email: string) =>
<div className="card" style={{ maxWidth: 420, width: "100%" }}>
<div style={{ textAlign: "center", marginBottom: 24 }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>📧</div>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--text-primary)" }}>Mail Hesabına Bağlan</h2>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--text-primary)" }}>{dict.mailClient.loginTitle || "IMAP Girişi"}</h2>
<p style={{ fontSize: 13, color: "var(--text-secondary)", marginTop: 4 }}>
Mailcow mail hesabınızın bilgilerini girin
{dict.mailClient.loginSubtitle || "Mail kutunuza bağlanın"}
</p>
</div>
<form onSubmit={handleSubmit} className="form-group">
{error && <div className="error-msg">{error}</div>}
<div>
<label className="label">E-posta Adresi</label>
<input type="email" className="input" placeholder="info@domain.com" value={email}
<label className="label">{dict.mailClient.emailLabel || "E-posta"}</label>
<input type="email" className="input" placeholder={dict.mailClient.emailPlaceholder || "isim@domain.com"} value={email}
onChange={(e) => setEmail(e.target.value)} required autoFocus />
</div>
<div>
<label className="label">Şifre</label>
<input type="password" className="input" placeholder="Mail hesabı şifresi" value={password}
<label className="label">{dict.mailClient.passwordLabel || "Şifre"}</label>
<input type="password" className="input" placeholder={dict.mailClient.passwordPlaceholder || "********"} value={password}
onChange={(e) => setPassword(e.target.value)} required />
</div>
<button type="submit" className="btn btn-primary" style={{ width: "100%" }} disabled={loading}>
{loading ? <span className="spinner" /> : "Bağlan"}
{loading ? <span className="spinner" /> : (dict.mailClient.connect || "Bağlan")}
</button>
<p style={{ fontSize: 11, color: "var(--text-muted)", textAlign: "center", marginTop: 8 }}>
Şifreniz sunucuda saklanmaz, sadece oturum süresince kullanılır.
</p>
</form>
</div>
</div>

View File

@@ -1,23 +1,25 @@
"use client";
import type { MailEnvelope } from "@/app/[lang]/dashboard/mail/page";
import { useDictionary } from "@/components/DictionaryContext";
import { useParams } from "next/navigation";
function timeAgo(dateStr: string): string {
function timeAgo(dateStr: string, lang: string): string {
const now = new Date();
const d = new Date(dateStr);
const diff = now.getTime() - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "şimdi";
if (mins < 60) return `${mins}dk`;
if (mins < 1) return lang === "tr" ? "şimdi" : "now";
if (mins < 60) return `${mins}${lang === "tr" ? "dk" : "m"}`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}sa`;
if (hrs < 24) return `${hrs}${lang === "tr" ? "sa" : "h"}`;
const days = Math.floor(hrs / 24);
if (days < 7) return `${days}g`;
return d.toLocaleDateString("tr-TR", { day: "numeric", month: "short" });
if (days < 7) return `${days}${lang === "tr" ? "g" : "d"}`;
return d.toLocaleDateString(lang === "tr" ? "tr-TR" : "en-US", { day: "numeric", month: "short" });
}
function senderName(msg: MailEnvelope): string {
const f = msg.from[0];
return f?.name || f?.address || "Bilinmeyen";
return f?.name || f?.address || "Unknown";
}
export default function MessageList({ messages, loading, selectedUid, onSelect, onDelete }: {
@@ -27,6 +29,10 @@ export default function MessageList({ messages, loading, selectedUid, onSelect,
onSelect: (uid: number) => void;
onDelete: (uid: number) => void;
}) {
const dict = useDictionary();
const params = useParams();
const lang = (params.lang as string) || "en";
if (loading) {
return <div className="empty-state" style={{ padding: 40 }}><span className="spinner" style={{ width: 20, height: 20 }} /></div>;
}
@@ -34,7 +40,7 @@ export default function MessageList({ messages, loading, selectedUid, onSelect,
if (messages.length === 0) {
return (
<div className="empty-state" style={{ padding: 40 }}>
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>Bu klasörde mail yok</div>
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>{dict.mailClient.emptyFolder || "Bu klasör boş"}</div>
</div>
);
}
@@ -53,11 +59,11 @@ export default function MessageList({ messages, loading, selectedUid, onSelect,
<div className="message-content">
<div className="message-top">
<span className="message-sender">{senderName(m)}</span>
<span className="message-time">{timeAgo(m.date)}</span>
<span className="message-time">{timeAgo(m.date, lang)}</span>
</div>
<div className="message-subject">{m.subject}</div>
<div className="message-subject">{m.subject || dict.mailClient.noSubject || "(Konu Yok)"}</div>
</div>
{m.hasAttachments && <span className="message-attach" title="Ek var">📎</span>}
{m.hasAttachments && <span className="message-attach" title={dict.mailClient.attachments || "Ekler"}>📎</span>}
</div>
))}
</div>

View File

@@ -1,6 +1,8 @@
"use client";
import type { MailMessage } from "@/app/[lang]/dashboard/mail/page";
import { formatBytes } from "@/lib/format";
import { useDictionary } from "@/components/DictionaryContext";
import { useParams } from "next/navigation";
function getFileIcon(contentType: string, filename: string): string {
if (contentType.startsWith("image/")) return "🖼️";
@@ -24,8 +26,11 @@ export default function MessageView({ message, onReply, onDelete, folder }: {
onDelete: () => void;
folder: string;
}) {
const dict = useDictionary();
const params = useParams();
const lang = (params.lang as string) || "en";
const from = message.from[0];
const date = new Date(message.date).toLocaleString("tr-TR", {
const date = new Date(message.date).toLocaleString(lang === "tr" ? "tr-TR" : "en-US", {
day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit",
});
@@ -48,13 +53,13 @@ export default function MessageView({ message, onReply, onDelete, folder }: {
<div className="message-view">
{/* Header */}
<div className="message-view-header">
<h2 className="message-view-subject">{message.subject}</h2>
<h2 className="message-view-subject">{message.subject || dict.mailClient.noSubject || "(Konu Yok)"}</h2>
<div className="message-view-actions">
<button className="btn btn-ghost btn-sm" onClick={onReply} title="Yanıtla">
<ReplyIcon /> Yanıtla
<button className="btn btn-ghost btn-sm" onClick={onReply} title={dict.mailClient.reply || "Yanıtla"}>
<ReplyIcon /> {dict.mailClient.reply || "Yanıtla"}
</button>
<button className="btn btn-danger btn-sm" onClick={onDelete} title="Sil">
<TrashIcon /> Sil
<button className="btn btn-danger btn-sm" onClick={onDelete} title={dict.mailClient.delete || "Sil"}>
<TrashIcon /> {dict.mailClient.delete || "Sil"}
</button>
</div>
</div>
@@ -76,7 +81,7 @@ export default function MessageView({ message, onReply, onDelete, folder }: {
{message.attachments.length > 0 && (
<div className="message-attachments">
<div style={{ width: "100%", fontSize: 12, fontWeight: 600, color: "var(--text-secondary)", marginBottom: 6 }}>
📎 {message.attachments.length} ek
📎 {message.attachments.length} {dict.mailClient.attachments || "ek"}
</div>
{message.attachments.map((att, i) => (
<div key={i} className="attachment-chip" onClick={() => handleAttachment(att, false)}>
@@ -89,7 +94,7 @@ export default function MessageView({ message, onReply, onDelete, folder }: {
<button
className="att-btn"
onClick={(e) => { e.stopPropagation(); handleAttachment(att, false); }}
title="İndir"
title={dict.mailClient.download || "İndir"}
>
</button>

View File

@@ -1,16 +1,12 @@
/**
* lib/mail-session.ts
* Stores/retrieves mail credentials in an encrypted httpOnly cookie.
* Credentials never hit the database.
*/
import { cookies } from "next/headers";
import { auth } from "@/auth";
const COOKIE_NAME = "ayrismail_creds";
export interface MailSessionData {
email: string;
password: string;
ownerId: string; // Dashboard user ID who owns this mail session
}
/**
@@ -30,9 +26,19 @@ function decode(token: string): MailSessionData | null {
}
/** Save mail credentials to cookie */
export async function setMailSession(data: MailSessionData): Promise<void> {
export async function setMailSession(data: Omit<MailSessionData, "ownerId">): Promise<void> {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Cannot set mail session without dashboard session");
}
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, encode(data), {
const sessionData: MailSessionData = {
...data,
ownerId: session.user.id,
};
cookieStore.set(COOKIE_NAME, encode(sessionData), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
@@ -43,10 +49,24 @@ export async function setMailSession(data: MailSessionData): Promise<void> {
/** Get mail credentials from cookie */
export async function getMailSession(): Promise<MailSessionData | null> {
const session = await auth();
if (!session?.user?.id) return null;
const cookieStore = await cookies();
const cookie = cookieStore.get(COOKIE_NAME);
if (!cookie?.value) return null;
return decode(cookie.value);
const data = decode(cookie.value);
if (!data) return null;
// Verify that this mail session belongs to the current dashboard user
if (data.ownerId !== session.user.id) {
// Session belongs to another user, clear it for safety
await clearMailSession();
return null;
}
return data;
}
/** Clear mail credentials cookie */

View File

@@ -4,11 +4,20 @@
* Uses the single super-admin API key from .env.
*/
const BASE = process.env.MAILCOW_API_URL?.replace(/\/$/, "") ?? "";
let BASE = process.env.MAILCOW_API_URL?.replace(/\/$/, "") ?? "";
if (BASE && !BASE.startsWith("http")) {
BASE = `https://${BASE}`;
}
const KEY = process.env.MAILCOW_API_KEY ?? "";
async function mfetch(path: string, options: RequestInit = {}) {
if (!BASE || !KEY) {
console.error("[Mailcow API] MAILCOW_API_URL or MAILCOW_API_KEY is not set in .env");
return new Response(JSON.stringify({ error: "Server configuration error" }), { status: 500 });
}
const url = `${BASE}/api/v1${path}`;
console.log(`[Mailcow API] ${options.method || "GET"} ${url}`);
try {
const res = await fetch(url, {
...options,
headers: {
@@ -16,10 +25,17 @@ async function mfetch(path: string, options: RequestInit = {}) {
"X-API-Key": KEY,
...options.headers,
},
// Don't cache — always fresh
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
console.error(`[Mailcow API] Error ${res.status}: ${text}`);
}
return res;
} catch (err: any) {
console.error(`[Mailcow API] Fetch failed: ${err.message}`);
return new Response(JSON.stringify({ error: "Connection to Mailcow failed" }), { status: 503 });
}
}
// ─── Types ─────────────────────────────────────────────────
@@ -88,11 +104,14 @@ export async function createDomain(payload: {
active: 1,
aliases: 400,
mailboxes: 10,
defquota: 3072,
maxquota: 10240,
defquota: Math.min(payload.quota || 10240, 3072),
maxquota: payload.quota || 10240,
quota: 10240,
...payload,
};
// Double check constraints
if (body.maxquota > body.quota) body.maxquota = body.quota;
if (body.defquota > body.maxquota) body.defquota = body.maxquota;
const res = await mfetch("/add/domain", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };

View File

@@ -23,6 +23,7 @@ export interface AppUser {
password: string; // plain text — store hashed in prod or use secrets manager
role: "SUPER_ADMIN" | "DOMAIN_ADMIN";
domains: string[]; // ["*"] for super admin, ["domain.com"] for domain admins
telegramId?: string; // Optional Telegram ID for notifications
}
/** Load all users defined in environment variables */
@@ -36,6 +37,7 @@ export function getUsers(): AppUser[] {
const password = process.env[`USER_${i}_PASSWORD`];
const role = process.env[`USER_${i}_ROLE`] as AppUser["role"];
const domainsRaw = process.env[`USER_${i}_DOMAINS`] ?? "";
const telegramId = process.env[`USER_${i}_TELEGRAM_ID`];
if (!name || !email || !password) break;
@@ -46,6 +48,7 @@ export function getUsers(): AppUser[] {
password,
role: role ?? "DOMAIN_ADMIN",
domains: domainsRaw === "*" ? ["*"] : domainsRaw.split(",").map((d) => d.trim()).filter(Boolean),
telegramId,
});
i++;