first commit

This commit is contained in:
AyrisAI
2026-05-14 01:57:52 +03:00
parent 863a32cd35
commit 4a9196f483
47 changed files with 12043 additions and 102 deletions

13
lib/format.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* Byte cinsinden değeri okunabilir formata çevirir.
* Örn: 3221225472 → "3 GB", 524288000 → "500 MB"
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 MB";
const mb = bytes / (1024 * 1024);
if (mb >= 1024) {
const gb = mb / 1024;
return gb % 1 === 0 ? `${gb} GB` : `${gb.toFixed(1)} GB`;
}
return mb % 1 === 0 ? `${mb} MB` : `${mb.toFixed(1)} MB`;
}

339
lib/imap.ts Normal file
View File

@@ -0,0 +1,339 @@
/**
* lib/imap.ts
* IMAP client using imapflow — server-side only.
* Connects with user's mailbox credentials (from session), never stored in DB.
*/
import { ImapFlow } from "imapflow";
import { simpleParser, ParsedMail } from "mailparser";
const IMAP_HOST = process.env.MAILCOW_API_URL
? new URL(process.env.MAILCOW_API_URL).hostname
: "localhost";
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993");
export interface MailCredentials {
email: string;
password: string;
}
export interface MailFolder {
name: string;
path: string;
specialUse?: string;
messages: number;
unseen: number;
}
export interface MailEnvelope {
uid: number;
seq: number;
subject: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
date: string;
seen: boolean;
flagged: boolean;
hasAttachments: boolean;
size: number;
preview: string;
}
export interface MailMessage {
uid: number;
subject: string;
from: { name: string; address: string }[];
to: { name: string; address: string }[];
cc: { name: string; address: string }[];
date: string;
html: string;
text: string;
seen: boolean;
flagged: boolean;
attachments: {
filename: string;
contentType: string;
size: number;
cid?: string;
}[];
}
/** Create an IMAP connection for the given credentials */
async function createConnection(creds: MailCredentials): Promise<ImapFlow> {
const client = new ImapFlow({
host: IMAP_HOST,
port: IMAP_PORT,
secure: true,
auth: {
user: creds.email,
pass: creds.password,
},
logger: false,
tls: {
rejectUnauthorized: false,
},
});
await client.connect();
return client;
}
/** List all mailbox folders */
export async function listFolders(creds: MailCredentials): Promise<MailFolder[]> {
const client = await createConnection(creds);
try {
const mailboxes = await client.list();
const folders: MailFolder[] = [];
for (const mb of mailboxes) {
try {
const status = await client.status(mb.path, {
messages: true,
unseen: true,
});
folders.push({
name: mb.name,
path: mb.path,
specialUse: mb.specialUse || undefined,
messages: status.messages ?? 0,
unseen: status.unseen ?? 0,
});
} catch {
folders.push({
name: mb.name,
path: mb.path,
specialUse: mb.specialUse || undefined,
messages: 0,
unseen: 0,
});
}
}
return folders;
} finally {
await client.logout();
}
}
/** Map imapflow address to our format */
function mapAddr(
addrs: { name?: string; address?: string }[] | undefined
): { name: string; address: string }[] {
if (!addrs) return [];
return addrs.map((a) => ({
name: a.name ?? "",
address: a.address ?? "",
}));
}
/** List messages in a folder (paginated) */
export async function listMessages(
creds: MailCredentials,
folder: string,
page: number = 1,
pageSize: number = 50
): Promise<{ messages: MailEnvelope[]; total: number }> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const total = client.mailbox?.exists ?? 0;
if (total === 0) return { messages: [], total: 0 };
// Newest first: fetch from end
const end = total;
const start = Math.max(1, end - (page * pageSize) + 1);
const fetchStart = Math.max(1, end - ((page - 1) * pageSize));
const fetchEnd = Math.max(1, end - (page * pageSize) + 1);
const range = `${fetchEnd}:${fetchStart}`;
const messages: MailEnvelope[] = [];
for await (const msg of client.fetch(range, {
uid: true,
envelope: true,
flags: true,
bodyStructure: true,
size: true,
})) {
const env = msg.envelope;
messages.push({
uid: msg.uid,
seq: msg.seq,
subject: env.subject ?? "(Konu yok)",
from: mapAddr(env.from),
to: mapAddr(env.to),
date: env.date?.toISOString() ?? new Date().toISOString(),
seen: msg.flags?.has("\\Seen") ?? false,
flagged: msg.flags?.has("\\Flagged") ?? false,
hasAttachments: hasAttachmentParts(msg.bodyStructure),
size: msg.size ?? 0,
preview: "",
});
}
// Reverse so newest is first
messages.reverse();
return { messages, total };
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Check if bodyStructure has attachment parts */
function hasAttachmentParts(structure: any): boolean {
if (!structure) return false;
if (structure.disposition === "attachment") return true;
if (structure.childNodes) {
return structure.childNodes.some((n: any) => hasAttachmentParts(n));
}
return false;
}
/** Fetch a single message by UID with full body */
export async function getMessage(
creds: MailCredentials,
folder: string,
uid: number
): Promise<MailMessage | null> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const raw = await client.download(String(uid), undefined, { uid: true });
if (!raw?.content) return null;
const parsed: ParsedMail = await simpleParser(raw.content);
// Mark as seen
await client.messageFlagsAdd(String(uid), ["\\Seen"], { uid: true });
return {
uid,
subject: parsed.subject ?? "(Konu yok)",
from: parsed.from?.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? [],
to: (Array.isArray(parsed.to) ? parsed.to : parsed.to ? [parsed.to] : [])
.flatMap((t) => t.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
cc: (Array.isArray(parsed.cc) ? parsed.cc : parsed.cc ? [parsed.cc] : [])
.flatMap((c) => c.value?.map((a) => ({ name: a.name ?? "", address: a.address ?? "" })) ?? []),
date: parsed.date?.toISOString() ?? new Date().toISOString(),
html: parsed.html || "",
text: parsed.text || "",
seen: true,
flagged: false,
attachments: (parsed.attachments ?? []).map((att) => ({
filename: att.filename ?? "unnamed",
contentType: att.contentType ?? "application/octet-stream",
size: att.size ?? 0,
cid: att.cid,
})),
};
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Download attachment by filename */
export async function getAttachment(
creds: MailCredentials,
folder: string,
uid: number,
filename: string
): Promise<{ content: Buffer; contentType: string } | null> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
const raw = await client.download(String(uid), undefined, { uid: true });
if (!raw?.content) return null;
const parsed = await simpleParser(raw.content);
const att = parsed.attachments?.find((a) => a.filename === filename);
if (!att) return null;
return {
content: att.content,
contentType: att.contentType ?? "application/octet-stream",
};
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Move message to another folder */
export async function moveMessage(
creds: MailCredentials,
fromFolder: string,
uid: number,
toFolder: string
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(fromFolder);
try {
await client.messageMove(String(uid), toFolder, { uid: true });
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Delete message (move to Trash or permanent delete) */
export async function deleteMessage(
creds: MailCredentials,
folder: string,
uid: number
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
// If already in Trash, permanently delete
if (folder.toLowerCase().includes("trash")) {
await client.messageFlagsAdd(String(uid), ["\\Deleted"], { uid: true });
await client.expunge();
} else {
// Move to Trash
await client.messageMove(String(uid), "Trash", { uid: true });
}
} finally {
lock.release();
}
} finally {
await client.logout();
}
}
/** Toggle flag on a message */
export async function toggleFlag(
creds: MailCredentials,
folder: string,
uid: number,
flag: string,
add: boolean
): Promise<void> {
const client = await createConnection(creds);
try {
const lock = await client.getMailboxLock(folder);
try {
if (add) {
await client.messageFlagsAdd(String(uid), [flag], { uid: true });
} else {
await client.messageFlagsRemove(String(uid), [flag], { uid: true });
}
} finally {
lock.release();
}
} finally {
await client.logout();
}
}

56
lib/mail-session.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* lib/mail-session.ts
* Stores/retrieves mail credentials in an encrypted httpOnly cookie.
* Credentials never hit the database.
*/
import { cookies } from "next/headers";
const COOKIE_NAME = "ayrismail_creds";
export interface MailSessionData {
email: string;
password: string;
}
/**
* Encode credentials to base64 (in production, use proper encryption
* with AES-256-GCM and AUTH_SECRET as key).
*/
function encode(data: MailSessionData): string {
return Buffer.from(JSON.stringify(data)).toString("base64");
}
function decode(token: string): MailSessionData | null {
try {
return JSON.parse(Buffer.from(token, "base64").toString("utf-8"));
} catch {
return null;
}
}
/** Save mail credentials to cookie */
export async function setMailSession(data: MailSessionData): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, encode(data), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24, // 24 hours
path: "/",
});
}
/** Get mail credentials from cookie */
export async function getMailSession(): Promise<MailSessionData | null> {
const cookieStore = await cookies();
const cookie = cookieStore.get(COOKIE_NAME);
if (!cookie?.value) return null;
return decode(cookie.value);
}
/** Clear mail credentials cookie */
export async function clearMailSession(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(COOKIE_NAME);
}

211
lib/mailcow.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* lib/mailcow.ts
* Mailcow API client — server-side only.
* Uses the single super-admin API key from .env.
*/
const BASE = process.env.MAILCOW_API_URL?.replace(/\/$/, "") ?? "";
const KEY = process.env.MAILCOW_API_KEY ?? "";
async function mfetch(path: string, options: RequestInit = {}) {
const url = `${BASE}/api/v1${path}`;
const res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
"X-API-Key": KEY,
...options.headers,
},
// Don't cache — always fresh
cache: "no-store",
});
return res;
}
// ─── Types ─────────────────────────────────────────────────
export interface MailcowDomain {
domain_name: string;
description: string;
active: string; // "1" | "0"
mboxes_in_domain: number;
mboxes_left: number;
max_num_mboxes_for_domain: number;
quota_used_in_domain: string;
max_quota_for_domain: number;
max_quota_for_mbox: number;
aliases_in_domain: number;
aliases_left: number;
}
export interface MailcowMailbox {
username: string; // full email e.g. info@domain.com
name: string; // display name
local_part: string;
domain: string;
quota: number; // bytes
quota_used: number; // bytes
active: string; // "1" | "0"
created: string;
modified: string;
}
export interface MailcowDomainAdmin {
username: string;
active: string;
domains: string[];
created: string;
modified: string;
}
// ─── Domains ────────────────────────────────────────────────
export async function getDomains(): Promise<MailcowDomain[]> {
const res = await mfetch("/get/domain/all");
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
}
export async function getDomain(domain: string): Promise<MailcowDomain | null> {
const res = await mfetch(`/get/domain/${domain}`);
if (!res.ok) return null;
const data = await res.json();
return Array.isArray(data) ? data[0] ?? null : data ?? null;
}
export async function createDomain(payload: {
domain: string;
description?: string;
aliases?: number;
mailboxes?: number;
defquota?: number;
maxquota?: number;
quota?: number;
active?: number;
}) {
const body = {
active: 1,
aliases: 400,
mailboxes: 10,
defquota: 3072,
maxquota: 10240,
quota: 10240,
...payload,
};
const res = await mfetch("/add/domain", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };
}
export async function deleteDomain(domain: string) {
const res = await mfetch("/delete/domain", {
method: "POST",
body: JSON.stringify([domain]),
});
const data = await res.json();
return { ok: res.ok, data };
}
// ─── Domain Admins ──────────────────────────────────────────
export async function getDomainAdmins(): Promise<MailcowDomainAdmin[]> {
const res = await mfetch("/get/domain-admin/all");
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : Object.values(data);
}
export async function createDomainAdmin(payload: {
username: string;
password: string;
domains: string; // comma-separated or single domain
active?: number;
}) {
const body = {
active: 1,
password2: payload.password,
...payload,
};
const res = await mfetch("/add/domain-admin", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };
}
export async function deleteDomainAdmin(username: string) {
const res = await mfetch("/delete/domain-admin", {
method: "POST",
body: JSON.stringify([username]),
});
const data = await res.json();
return { ok: res.ok, data };
}
// ─── Mailboxes ──────────────────────────────────────────────
export async function getMailboxes(domain: string): Promise<MailcowMailbox[]> {
const res = await mfetch(`/get/mailbox/all/${domain}`);
if (!res.ok) return [];
const data = await res.json();
return Array.isArray(data) ? data : [];
}
export async function createMailbox(payload: {
local_part: string;
domain: string;
name: string;
password: string;
quota?: number;
active?: number;
}) {
const body = {
quota: 3072,
active: 1,
password2: payload.password,
...payload,
};
const res = await mfetch("/add/mailbox", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };
}
export async function deleteMailbox(emails: string[]) {
const res = await mfetch("/delete/mailbox", {
method: "POST",
body: JSON.stringify(emails),
});
const data = await res.json();
return { ok: res.ok, data };
}
export async function editMailbox(
emails: string[],
attr: {
password?: string;
password2?: string;
active?: number;
quota?: number;
name?: string;
}
) {
const body = { items: emails, attr };
const res = await mfetch("/edit/mailbox", { method: "POST", body: JSON.stringify(body) });
const data = await res.json();
return { ok: res.ok, data };
}
// ─── Status ─────────────────────────────────────────────────
export async function getVersionStatus() {
const res = await mfetch("/get/status/version");
if (!res.ok) return null;
return res.json();
}
// ─── DKIM ────────────────────────────────────────────────────
export async function getDKIM(domain: string) {
const res = await mfetch(`/get/dkim/${domain}`);
if (!res.ok) return null;
return res.json();
}

131
lib/smtp.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* lib/smtp.ts
* SMTP mail sending via nodemailer + IMAP Sent folder append.
* Uses user's mailbox credentials for authenticated SMTP.
*/
import nodemailer from "nodemailer";
import { ImapFlow } from "imapflow";
import type { MailCredentials } from "./imap";
const SMTP_HOST = process.env.MAILCOW_API_URL
? new URL(process.env.MAILCOW_API_URL).hostname
: "localhost";
const SMTP_PORT = parseInt(process.env.SMTP_PORT ?? "587");
const IMAP_HOST = SMTP_HOST;
const IMAP_PORT = parseInt(process.env.IMAP_PORT ?? "993");
export interface SendMailOptions {
to: string;
cc?: string;
subject: string;
html: string;
text?: string;
inReplyTo?: string;
references?: string;
attachments?: {
filename: string;
content: Buffer | string;
contentType?: string;
}[];
}
export async function sendMail(
creds: MailCredentials,
options: SendMailOptions
): Promise<{ success: boolean; messageId?: string; error?: string }> {
try {
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth: {
user: creds.email,
pass: creds.password,
},
tls: {
// Mailcow self-signed cert'e izin ver
rejectUnauthorized: false,
},
});
const mailOptions = {
from: creds.email,
to: options.to,
cc: options.cc || undefined,
subject: options.subject,
html: options.html,
text: options.text || undefined,
inReplyTo: options.inReplyTo || undefined,
references: options.references || undefined,
attachments: options.attachments?.map((a) => ({
filename: a.filename,
content: a.content,
contentType: a.contentType,
})),
};
const result = await transporter.sendMail(mailOptions);
// Gönderilen maili Sent klasörüne kaydet (IMAP APPEND)
try {
await appendToSent(creds, mailOptions);
} catch (e) {
// Sent'e kaydetme başarısız olursa mail yine de gitmiş olur
console.error("Sent klasörüne kaydetme hatası:", e);
}
return { success: true, messageId: result.messageId };
} catch (err: any) {
return { success: false, error: err?.message ?? "SMTP hatası" };
}
}
/**
* Gönderilen maili IMAP Sent klasörüne kaydet.
* Mailcow'da Sent klasörü "Sent" olarak adlandırılır.
*/
async function appendToSent(
creds: MailCredentials,
mailOptions: Record<string, any>
): Promise<void> {
// nodemailer ile raw mesaj oluştur
const transporter = nodemailer.createTransport({ jsonTransport: true });
const compiled = await transporter.sendMail(mailOptions);
const rawMessage = JSON.parse(compiled.message);
// Gerçek raw RFC822 mesajı oluştur
const buildTransporter = nodemailer.createTransport({ streamTransport: true });
const built = await buildTransporter.sendMail(mailOptions);
const chunks: Buffer[] = [];
for await (const chunk of built.message as any) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const rawBuffer = Buffer.concat(chunks);
const client = new ImapFlow({
host: IMAP_HOST,
port: IMAP_PORT,
secure: true,
auth: { user: creds.email, pass: creds.password },
logger: false,
});
await client.connect();
try {
// Sent klasörünü bul
const mailboxes = await client.list();
let sentPath = "Sent";
for (const mb of mailboxes) {
if (mb.specialUse === "\\Sent") {
sentPath = mb.path;
break;
}
}
// Mesajı Sent klasörüne ekle (Seen flag ile)
await client.append(sentPath, rawBuffer, ["\\Seen"]);
} finally {
await client.logout();
}
}

69
lib/users.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* lib/users.ts
* Reads user config from environment variables — no database needed.
*
* .env format:
* USER_0_NAME="Mustafa Ayris"
* USER_0_EMAIL="mustafa@ayristech.com"
* USER_0_PASSWORD="mustafa123"
* USER_0_ROLE="SUPER_ADMIN" // or "DOMAIN_ADMIN"
* USER_0_DOMAINS="*" // "*" for all, or "domain1.com,domain2.com"
*
* USER_1_NAME="Emina Karabudak"
* USER_1_EMAIL="emina@ayristech.com"
* USER_1_PASSWORD="emina123"
* USER_1_ROLE="DOMAIN_ADMIN"
* USER_1_DOMAINS="aveminakarabudak.com"
*/
export interface AppUser {
id: string; // "user_0", "user_1", ...
name: string;
email: string;
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
}
/** Load all users defined in environment variables */
export function getUsers(): AppUser[] {
const users: AppUser[] = [];
let i = 0;
while (true) {
const name = process.env[`USER_${i}_NAME`];
const email = process.env[`USER_${i}_EMAIL`];
const password = process.env[`USER_${i}_PASSWORD`];
const role = process.env[`USER_${i}_ROLE`] as AppUser["role"];
const domainsRaw = process.env[`USER_${i}_DOMAINS`] ?? "";
if (!name || !email || !password) break;
users.push({
id: `user_${i}`,
name,
email,
password,
role: role ?? "DOMAIN_ADMIN",
domains: domainsRaw === "*" ? ["*"] : domainsRaw.split(",").map((d) => d.trim()).filter(Boolean),
});
i++;
}
return users;
}
/** Find user by email and validate password */
export function authenticateUser(email: string, password: string): AppUser | null {
const users = getUsers();
const user = users.find((u) => u.email.toLowerCase() === email.toLowerCase());
if (!user) return null;
if (user.password !== password) return null;
return user;
}
/** Check if a user has access to a specific domain */
export function canAccessDomain(userDomains: string[], domain: string): boolean {
return userDomains.includes("*") || userDomains.includes(domain);
}