Initial commit: mailbox

This commit is contained in:
caadiq 2025-12-16 08:18:15 +09:00
commit c1da47f057
93 changed files with 21844 additions and 0 deletions

16
.env Normal file
View file

@ -0,0 +1,16 @@
# Database
DB_HOST=mariadb
DB_USER=mail
DB_PASSWORD=Bv4@nXk6!pMy9^Gt
DB_NAME=mail
# App Config
JWT_SECRET=2scRswv/9fgsYLpsZ823VDWTVHvzRZ7NLjVxnL8o/vs=
TZ=Asia/Seoul
# S3 (RustFS) 설정
MINIO_ENDPOINT=http://rustfs:9000
MINIO_ACCESS_KEY=M2xkrH1f75mFWKIpNzYw
MINIO_SECRET_KEY=lR37iDTXNBkQrjSmbf4v1cgxFJh658VWnYMIzE0s
MINIO_BUCKET=emails
AWS_REGION=us-east-1

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
node_modules

13
backend/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]

View file

@ -0,0 +1,31 @@
const { Sequelize } = require("sequelize");
require("dotenv").config();
const sequelize = new Sequelize(
process.env.DB_NAME || "mail",
process.env.DB_USER || "root",
process.env.DB_PASSWORD || "password",
{
host: process.env.DB_HOST || "mariadb",
dialect: "mysql",
timezone: "+09:00",
logging: false,
pool: {
max: 10,
min: 0,
acquire: 60000,
idle: 10000,
},
// 연결 재시도 설정
retry: {
max: 3,
timeout: 3000,
},
// MariaDB 연결 유지 옵션
dialectOptions: {
connectTimeout: 60000,
},
}
);
module.exports = sequelize;

51
backend/index.js Normal file
View file

@ -0,0 +1,51 @@
/**
* 이메일 백엔드 서버 진입점
* Express HTTP 서버 + SMTP 서버 시작
*/
const express = require("express");
const cors = require("cors");
const dotenv = require("dotenv");
// 환경 변수 로드
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
// 미들웨어 설정
app.use(cors());
app.use(express.json({ limit: "50mb" })); // Base64 인코딩된 첨부파일 40MB 지원
// 헬스 체크 엔드포인트
app.get("/", (req, res) => {
res.send("Email Client Backend is running");
});
// 라우터 등록
const authRoutes = require("./routes/auth");
const mailRoutes = require("./routes/mail");
const adminRoutes = require("./routes/admin");
app.use("/api", authRoutes);
app.use("/api/admin", adminRoutes);
app.use("/api", mailRoutes);
// 서비스 및 유틸리티
const { startSMTPServer } = require("./services/smtpService");
const initializeDatabase = require("./utils/dbInit");
/**
* 서버 시작
* 1. SMTP 서버 시작 (포트 25)
* 2. 데이터베이스 초기화
* 3. HTTP 서버 시작
*/
const startServer = async () => {
startSMTPServer();
await initializeDatabase();
app.listen(port, () => {
console.log(`[서버] HTTP 서버 시작: http://localhost:${port}`);
});
};
startServer();

View file

@ -0,0 +1,33 @@
/**
* JWT 인증 미들웨어
* Authorization 헤더에서 Bearer 토큰을 추출하여 검증
*/
const jwt = require("jsonwebtoken");
const JWT_SECRET =
process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production";
/**
* JWT 토큰 인증 미들웨어
* @param {Request} req - Express 요청 객체
* @param {Response} res - Express 응답 객체
* @param {NextFunction} next - 다음 미들웨어
*/
const authenticateToken = (req, res, next) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) {
return res.status(401).json({ error: "인증 토큰이 필요합니다" });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: "유효하지 않은 토큰입니다" });
}
req.user = user;
next();
});
};
module.exports = { authenticateToken };

29
backend/models/Draft.js Normal file
View file

@ -0,0 +1,29 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Draft = sequelize.define(
"Draft",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: true }, // 임시저장은 받는 사람 없을 수 있음
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: true },
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false },
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
},
{
tableName: "drafts",
timestamps: true,
}
);
module.exports = Draft;

View file

@ -0,0 +1,60 @@
/**
* 이메일 번역 캐시 테이블
* 번역된 이메일 내용 저장
*/
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const EmailTranslation = sequelize.define(
"EmailTranslation",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
emailId: {
type: DataTypes.INTEGER,
allowNull: false,
field: "email_id",
comment: "원본 이메일 ID",
},
mailbox: {
type: DataTypes.STRING(20),
allowNull: false,
comment: "메일함 (inbox, sent, trash 등)",
},
targetLang: {
type: DataTypes.STRING(10),
allowNull: false,
field: "target_lang",
comment: "번역 대상 언어",
},
translatedContent: {
type: DataTypes.TEXT("long"),
allowNull: false,
field: "translated_content",
comment: "번역된 내용",
},
modelUsed: {
type: DataTypes.STRING(50),
allowNull: true,
field: "model_used",
comment: "사용된 AI 모델",
},
},
{
tableName: "email_translations",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
indexes: [
{
unique: true,
fields: ["email_id", "mailbox", "target_lang"],
},
],
}
);
module.exports = EmailTranslation;

View file

@ -0,0 +1,77 @@
/**
* Gemini 모델 테이블
* 사용 가능한 AI 모델 목록 저장
*/
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const GeminiModel = sequelize.define(
"GeminiModel",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
modelId: {
type: DataTypes.STRING(100),
allowNull: false,
unique: true,
field: "model_id",
comment: "모델 ID (예: gemini-2.5-flash)",
},
name: {
type: DataTypes.STRING(100),
allowNull: false,
comment: "모델 표시명",
},
description: {
type: DataTypes.TEXT,
allowNull: true,
comment: "모델 설명",
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: "sort_order",
comment: "정렬 순서",
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: "is_active",
comment: "활성화 여부",
},
},
{
tableName: "gemini_models",
timestamps: true,
createdAt: "created_at",
updatedAt: "updated_at",
}
);
// 기본 모델 데이터 시드
GeminiModel.seedDefaultModels = async () => {
const defaults = [
{ modelId: "gemini-3-pro-preview", name: "Gemini 3 Pro", sortOrder: 1 },
{ modelId: "gemini-2.5-pro", name: "Gemini 2.5 Pro", sortOrder: 2 },
{ modelId: "gemini-2.5-flash", name: "Gemini 2.5 Flash", sortOrder: 3 },
{
modelId: "gemini-2.5-flash-lite",
name: "Gemini 2.5 Flash-Lite",
sortOrder: 4,
},
];
for (const model of defaults) {
await GeminiModel.findOrCreate({
where: { modelId: model.modelId },
defaults: model,
});
}
};
module.exports = GeminiModel;

View file

@ -0,0 +1,30 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Important = sequelize.define(
"Important",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: false },
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: false },
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false },
originalMailbox: { type: DataTypes.STRING, allowNull: true }, // 중요 편지함은 참조 개념일 수도 있으나 우선 독립 테이블로 구성
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
},
{
tableName: "important",
timestamps: true,
}
);
module.exports = Important;

32
backend/models/Inbox.js Normal file
View file

@ -0,0 +1,32 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Inbox = sequelize.define(
"Inbox",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: false },
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: false },
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false },
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
// rspamd 스팸 필터 관련 필드
spamScore: { type: DataTypes.FLOAT, allowNull: true },
rawEmail: { type: DataTypes.TEXT("long"), allowNull: true },
},
{
tableName: "inbox",
timestamps: true,
}
);
module.exports = Inbox;

29
backend/models/Sent.js Normal file
View file

@ -0,0 +1,29 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Sent = sequelize.define(
"Sent",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: false },
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: true }, // 보낸 메일은 기본 읽음
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false },
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
},
{
tableName: "sent",
timestamps: true,
}
);
module.exports = Sent;

48
backend/models/SentLog.js Normal file
View file

@ -0,0 +1,48 @@
/**
* 발송 로그 모델
* 메일 발송 이력을 기록 (보낸편지함에서 삭제해도 통계 유지)
*/
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const SentLog = sequelize.define(
"SentLog",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
from: {
type: DataTypes.STRING,
allowNull: false,
comment: "발신자 이메일",
},
to: {
type: DataTypes.STRING,
allowNull: false,
comment: "수신자 이메일",
},
subject: {
type: DataTypes.STRING,
allowNull: true,
comment: "메일 제목",
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: "발송 성공 여부",
},
sentAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
comment: "발송 시간",
},
},
{
tableName: "sent_logs",
timestamps: false,
}
);
module.exports = SentLog;

73
backend/models/SmtpLog.js Normal file
View file

@ -0,0 +1,73 @@
/**
* SMTP 접속 로그 모델
* 25포트로 접근한 클라이언트 IP와 국가 정보를 기록
*/
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const SmtpLog = sequelize.define(
"SmtpLog",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
remoteAddress: {
type: DataTypes.STRING,
allowNull: false,
comment: "접속한 클라이언트 IP 주소",
},
country: {
type: DataTypes.STRING(10),
allowNull: true,
comment: "국가 코드 (예: KR, US)",
},
countryName: {
type: DataTypes.STRING(100),
allowNull: true,
comment: "국가 이름 (예: South Korea)",
},
hostname: {
type: DataTypes.STRING,
allowNull: true,
comment: "클라이언트 호스트명 (있는 경우)",
},
mailFrom: {
type: DataTypes.STRING,
allowNull: true,
comment: "발신자 이메일",
},
rcptTo: {
type: DataTypes.STRING,
allowNull: true,
comment: "수신자 이메일",
},
success: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: "메일 수신 성공 여부",
},
isSpam: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: "스팸 여부",
},
spamScore: {
type: DataTypes.FLOAT,
allowNull: true,
comment: "스팸 점수",
},
connectedAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
comment: "접속 시간",
},
},
{
tableName: "smtp_logs",
timestamps: false,
}
);
module.exports = SmtpLog;

33
backend/models/Spam.js Normal file
View file

@ -0,0 +1,33 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Spam = sequelize.define(
"Spam",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: false },
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: false },
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: false },
originalMailbox: { type: DataTypes.STRING, allowNull: true },
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
// rspamd 스팸 필터 관련 필드
spamScore: { type: DataTypes.FLOAT, allowNull: true },
rawEmail: { type: DataTypes.TEXT("long"), allowNull: true },
},
{
tableName: "spam",
timestamps: true,
}
);
module.exports = Spam;

View file

@ -0,0 +1,41 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const SystemConfig = sequelize.define(
"SystemConfig",
{
key: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
unique: true,
},
value: {
type: DataTypes.TEXT, // Using TEXT to store JSON or long strings
allowNull: true,
get() {
// Automatically parse JSON if possible
const rawValue = this.getDataValue("value");
try {
return JSON.parse(rawValue);
} catch (e) {
return rawValue;
}
},
set(value) {
// Automatically stringify objects
if (typeof value === "object" && value !== null) {
this.setDataValue("value", JSON.stringify(value));
} else {
this.setDataValue("value", value);
}
},
},
},
{
tableName: "system_configs",
timestamps: false, // No need for created/updatedAt for config
}
);
module.exports = SystemConfig;

30
backend/models/Trash.js Normal file
View file

@ -0,0 +1,30 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const Trash = sequelize.define(
"Trash",
{
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
messageId: { type: DataTypes.STRING, unique: false, allowNull: true },
from: { type: DataTypes.STRING, allowNull: false },
fromName: { type: DataTypes.STRING, allowNull: true },
to: { type: DataTypes.TEXT, allowNull: false },
subject: { type: DataTypes.STRING, defaultValue: "(No Subject)" },
text: { type: DataTypes.TEXT("long"), allowNull: true },
html: { type: DataTypes.TEXT("long"), allowNull: true },
attachments: { type: DataTypes.JSON, defaultValue: [] },
date: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
flags: { type: DataTypes.JSON, defaultValue: [] },
isRead: { type: DataTypes.BOOLEAN, defaultValue: false },
isDeleted: { type: DataTypes.BOOLEAN, defaultValue: true }, // 항상 true
originalMailbox: { type: DataTypes.STRING, allowNull: true }, // 복구를 위한 원래 위치 저장
// 메일 크기 (bytes) - 검색 필터용
size: { type: DataTypes.INTEGER, allowNull: true, defaultValue: 0 },
},
{
tableName: "trash",
timestamps: true,
}
);
module.exports = Trash;

42
backend/models/User.js Normal file
View file

@ -0,0 +1,42 @@
const { DataTypes } = require("sequelize");
const sequelize = require("../config/database");
const User = sequelize.define(
"User",
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: true,
},
isAdmin: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
createdAt: {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW,
},
},
{
tableName: "users",
}
);
module.exports = User;

25
backend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "email-backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.701.0",
"@aws-sdk/s3-request-presigner": "^3.701.0",
"@google/genai": "^1.0.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"imap-simple": "^5.1.0",
"jsonwebtoken": "^9.0.3",
"mailparser": "^3.7.1",
"mysql2": "^3.11.5",
"nodemailer": "^6.9.16",
"resend": "^6.6.0",
"sequelize": "^6.37.5",
"smtp-server": "^3.12.0"
}
}

746
backend/routes/admin.js Normal file
View file

@ -0,0 +1,746 @@
/**
* 관리자 라우터
* 통계 대시보드, 사용자 관리, SES 설정
*/
const express = require("express");
const router = express.Router();
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { Op, fn, col } = require("sequelize");
const User = require("../models/User");
const Inbox = require("../models/Inbox");
const Sent = require("../models/Sent");
const SystemConfig = require("../models/SystemConfig");
const SmtpLog = require("../models/SmtpLog");
const SentLog = require("../models/SentLog");
const Spam = require("../models/Spam");
const { sendEmail } = require("../services/emailService");
const rspamd = require("../services/rspamdService");
// 공통 헬퍼 함수 import
const {
calculateStorageSize,
getPeriodStartDate,
} = require("../utils/helpers");
const JWT_SECRET =
process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production";
// ============================================================================
// 미들웨어
// ============================================================================
/**
* 관리자 권한 검증 미들웨어
* JWT 토큰 검증 DB에서 실시간 권한 확인
* (권한이 박탈된 경우 즉시 차단)
*/
const verifyAdmin = async (req, res, next) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "토큰이 없습니다" });
try {
const decoded = jwt.verify(token, JWT_SECRET);
// DB에서 최신 사용자 정보 조회
const user = await User.findByPk(decoded.id);
if (!user) {
return res.status(403).json({ error: "사용자를 찾을 수 없습니다" });
}
// 실시간 관리자 권한 확인
if (!user.isAdmin) {
return res.status(403).json({ error: "관리자 권한이 필요합니다" });
}
req.user = {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
};
next();
} catch (err) {
return res.status(403).json({ error: "유효하지 않은 토큰" });
}
};
// 최고 관리자 계정 (삭제 불가, 비밀번호만 수정 가능)
const SUPER_ADMIN_EMAIL = "admin@caadiq.co.kr";
// 모든 라우트에 관리자 검증 적용
router.use(verifyAdmin);
// ============================================================================
// API 라우트 - 통계 대시보드
// ============================================================================
/**
* 대시보드 통계 조회
* GET /api/admin/stats
*/
router.get("/stats", async (req, res) => {
try {
const userCount = await User.count();
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
// 오늘 및 총 발송/수신 메일 수
const [
sentToday,
receivedToday,
spamToday,
totalSpam,
totalSent,
totalReceived,
] = await Promise.all([
SentLog.count({
where: { sentAt: { [Op.gte]: startOfDay }, success: true },
}),
SmtpLog.count({
where: { connectedAt: { [Op.gte]: startOfDay }, success: true },
}),
SmtpLog.count({
where: { connectedAt: { [Op.gte]: startOfDay }, isSpam: true },
}),
Spam.count(),
Sent.count(), // 보낸편지함 실제 메일 수
Inbox.count(), // 받은편지함 실제 메일 수
]);
// 스토리지 사용량 계산
const [inboxItems, sentItems] = await Promise.all([
Inbox.findAll({
attributes: ["attachments", "subject", "date", "text", "html"],
}),
Sent.findAll({
attributes: ["attachments", "subject", "date", "text", "html"],
}),
]);
const totalSize =
calculateStorageSize(inboxItems) + calculateStorageSize(sentItems);
const storageUsed = (totalSize / (1024 * 1024)).toFixed(2);
// 스토리지 쿼터 설정 (기본값 50GB)
const quotaConfig = await SystemConfig.findOne({
where: { key: "user_storage_quota" },
});
const storageLimit = quotaConfig ? parseInt(quotaConfig.value) : 51200;
// 최근 활동 로그 생성
const logs = [];
// 최근 수신 메일 (5개)
const recentInbox = [...inboxItems]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 5);
recentInbox.forEach((item) => {
logs.push({
type: "INBOX",
date: new Date(item.date),
message: `메일 수신: ${item.subject}`,
detail: item.subject,
});
});
// 최근 발송 메일 (5개)
const recentSent = [...sentItems]
.sort((a, b) => new Date(b.date) - new Date(a.date))
.slice(0, 5);
recentSent.forEach((item) => {
logs.push({
type: "SENT",
date: new Date(item.date),
message: `메일 발송: ${item.subject}`,
detail: item.subject,
});
});
// 최근 가입 사용자 (5개)
const recentUsers = await User.findAll({
limit: 5,
order: [["createdAt", "DESC"]],
attributes: ["email", "createdAt"],
});
recentUsers.forEach((user) => {
if (user.createdAt) {
logs.push({
type: "USER",
date: new Date(user.createdAt),
message: `사용자 가입: ${user.email}`,
detail: user.email,
});
}
});
// 로그 병합 후 최신순 정렬
logs.sort((a, b) => b.date - a.date);
const recentLogs = logs.slice(0, 5);
// 차트 데이터 (최근 7일) - sent_logs/smtp_logs 기반
const chartData = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split("T")[0];
const dayStart = new Date(d.setHours(0, 0, 0, 0));
const dayEnd = new Date(d.setHours(23, 59, 59, 999));
const [sent, received] = await Promise.all([
SentLog.count({
where: {
sentAt: { [Op.between]: [dayStart, dayEnd] },
success: true,
},
}),
SmtpLog.count({
where: {
connectedAt: { [Op.between]: [dayStart, dayEnd] },
success: true,
},
}),
]);
chartData.push({ name: dateStr.slice(5), sent, received });
}
// 시스템 상태 체크
let dbStatus = "Connected";
try {
await User.findOne();
} catch {
dbStatus = "Disconnected";
}
// rspamd 상태 체크
const rspamdStatus = (await rspamd.checkHealth()) ? "Running" : "Stopped";
// 기간 파라미터 처리 (1d, 7d, 30d, all)
const period = req.query.period || "all";
const periodStart = getPeriodStartDate(period);
// 유저별 메일 통계 (Top Users) - sent_logs/smtp_logs 기반
const allUsers = await User.findAll({ attributes: ["email"] });
const topUsersData = await Promise.all(
allUsers.map(async (user) => {
const sentWhere = { from: user.email, success: true };
const receivedWhere = {
rcptTo: { [Op.like]: `%${user.email}%` },
success: true,
};
if (periodStart) {
sentWhere.sentAt = { [Op.gte]: periodStart };
receivedWhere.connectedAt = { [Op.gte]: periodStart };
}
const [sentCount, receivedCount] = await Promise.all([
SentLog.count({ where: sentWhere }),
SmtpLog.count({ where: receivedWhere }),
]);
return {
email: user.email,
sent: sentCount,
received: receivedCount,
total: sentCount + receivedCount,
};
})
);
const topUsers = topUsersData.sort((a, b) => b.total - a.total).slice(0, 5);
// SMTP 접속 IP 통계 (Remote IPs) - 국가 정보 포함
const smtpLogWhere = periodStart
? { connectedAt: { [Op.gte]: periodStart } }
: {};
const remoteIpsData = await SmtpLog.findAll({
attributes: [
"remoteAddress",
"country",
"countryName",
"hostname",
[fn("COUNT", col("id")), "count"],
],
where: smtpLogWhere,
group: ["remoteAddress", "country", "countryName", "hostname"],
order: [[fn("COUNT", col("id")), "DESC"]],
limit: 10,
raw: true,
});
const remoteIps = remoteIpsData.map((item) => ({
ip: item.remoteAddress,
country: item.country,
countryName: item.countryName,
hostname: item.hostname,
count: parseInt(item.count),
}));
res.json({
userCount,
sentToday,
receivedToday,
spamToday,
totalSpam,
totalSent,
totalReceived,
storageUsed: storageUsed + " MB",
storageLimit,
chartData,
recentLogs,
topUsers,
remoteIps,
rspamdStatus,
dbStatus,
});
} catch (error) {
console.error("통계 조회 오류:", error);
res.status(500).json({ error: "통계 조회 실패" });
}
});
/**
* 접속 IP 목록 페이징 조회 (개별 로그)
* GET /api/admin/remote-ips?page=1&limit=20&period=all
*/
router.get("/remote-ips", async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개
const period = req.query.period || "all";
const maxItems = 50; // 최대 50개까지만 표시
const offset = (page - 1) * limit;
// 기간 필터
const periodStart = getPeriodStartDate(period);
const smtpLogWhere = periodStart
? { connectedAt: { [Op.gte]: periodStart } }
: {};
// 전체 개수 조회
const totalCount = await SmtpLog.count({ where: smtpLogWhere });
// 페이징된 개별 로그 조회
const remoteIpsData = await SmtpLog.findAll({
attributes: [
"remoteAddress",
"country",
"countryName",
"hostname",
"mailFrom",
"rcptTo",
"connectedAt",
],
where: smtpLogWhere,
order: [["connectedAt", "DESC"]],
limit,
offset,
raw: true,
});
const remoteIps = remoteIpsData.map((item) => ({
ip: item.remoteAddress,
country: item.country,
countryName: item.countryName,
hostname: item.hostname,
mailFrom: item.mailFrom,
rcptTo: item.rcptTo,
connectedAt: item.connectedAt,
}));
// 최대 50개로 제한된 전체 개수
const effectiveTotal = Math.min(totalCount, maxItems);
res.json({
data: remoteIps,
pagination: {
page,
limit,
total: effectiveTotal,
hasMore: offset + remoteIps.length < effectiveTotal,
},
});
} catch (error) {
console.error("접속 IP 조회 오류:", error);
res.status(500).json({ error: "접속 IP 조회 실패" });
}
});
/**
* 최근 활동 로그 페이징 조회
* GET /api/admin/recent-logs?page=1&limit=20
*/
router.get("/recent-logs", async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 10, 10); // 기본 10개, 최대 10개
const maxItems = 50; // 최대 50개까지만 표시
const offset = (page - 1) * limit;
const logs = [];
// 최근 수신 메일
const recentInbox = await Inbox.findAll({
order: [["date", "DESC"]],
attributes: ["from", "subject", "date"],
});
recentInbox.forEach((item) => {
logs.push({
type: "INBOX",
date: new Date(item.date),
message: `메일 수신: ${item.subject}`,
detail: item.from,
});
});
// 최근 발송 메일
const recentSent = await Sent.findAll({
order: [["date", "DESC"]],
attributes: ["to", "subject", "date"],
});
recentSent.forEach((item) => {
logs.push({
type: "SENT",
date: new Date(item.date),
message: `메일 발송: ${item.subject}`,
detail: item.to,
});
});
// 최근 가입 사용자
const recentUsers = await User.findAll({
order: [["createdAt", "DESC"]],
attributes: ["email", "createdAt"],
});
recentUsers.forEach((user) => {
if (user.createdAt) {
logs.push({
type: "USER",
date: new Date(user.createdAt),
message: `사용자 가입: ${user.email}`,
detail: user.email,
});
}
});
// 날짜순 정렬 후 페이징 (최대 50개)
logs.sort((a, b) => b.date - a.date);
const effectiveTotal = Math.min(logs.length, maxItems);
const paginatedLogs = logs.slice(
offset,
Math.min(offset + limit, effectiveTotal)
);
res.json({
data: paginatedLogs,
pagination: {
page,
limit,
total: effectiveTotal,
hasMore: offset + paginatedLogs.length < effectiveTotal,
},
});
} catch (error) {
console.error("최근 활동 조회 오류:", error);
res.status(500).json({ error: "최근 활동 조회 실패" });
}
});
// ============================================================================
// API 라우트 - 사용자 관리
// ============================================================================
/**
* 사용자 목록 조회
* GET /api/admin/users
*/
router.get("/users", async (req, res) => {
try {
const users = await User.findAll({ attributes: { exclude: ["password"] } });
res.json(users);
} catch (error) {
console.error("사용자 목록 조회 오류:", error);
res.status(500).json({ error: "사용자 목록 조회 실패" });
}
});
/**
* 사용자 생성
* POST /api/admin/users
*/
router.post("/users", async (req, res) => {
try {
const { email, password, name, isAdmin } = req.body;
// 중복 이메일 체크
const existing = await User.findOne({ where: { email } });
if (existing)
return res.status(400).json({ error: "이미 사용 중인 이메일입니다." });
const hashedPassword = await bcrypt.hash(password, 10);
const user = await User.create({
email,
password: hashedPassword,
name,
isAdmin,
});
res.json({ success: true, user: { id: user.id, email: user.email } });
} catch (error) {
console.error("사용자 생성 오류:", error);
res.status(500).json({ error: "사용자 생성 실패" });
}
});
/**
* 사용자 수정
* PUT /api/admin/users/:id
*/
router.put("/users/:id", async (req, res) => {
try {
const { password, name, isAdmin } = req.body;
const user = await User.findByPk(req.params.id);
if (!user) {
return res.status(404).json({ error: "사용자를 찾을 수 없습니다" });
}
// 최고 관리자는 비밀번호만 수정 가능
if (user.email === SUPER_ADMIN_EMAIL) {
if (password) {
user.password = await bcrypt.hash(password, 10);
await user.save();
}
return res.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
},
});
}
if (name !== undefined) user.name = name;
if (isAdmin !== undefined) user.isAdmin = isAdmin;
if (password) {
user.password = await bcrypt.hash(password, 10);
}
await user.save();
res.json({
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
},
});
} catch (error) {
console.error("사용자 수정 오류:", error);
res.status(500).json({ error: "사용자 수정 실패" });
}
});
/**
* 사용자 삭제
* DELETE /api/admin/users/:id
*/
router.delete("/users/:id", async (req, res) => {
try {
const user = await User.findByPk(req.params.id);
// 최고 관리자 삭제 금지
if (user && user.email === SUPER_ADMIN_EMAIL) {
return res
.status(403)
.json({ error: "최고 관리자 계정은 삭제할 수 없습니다" });
}
await User.destroy({ where: { id: req.params.id } });
res.json({ success: true });
} catch (error) {
console.error("사용자 삭제 오류:", error);
res.status(500).json({ error: "사용자 삭제 실패" });
}
});
// ============================================================================
// API 라우트 - Resend 설정
// ============================================================================
/**
* Resend 설정 조회
* GET /api/admin/config/email
*/
router.get("/config/email", async (req, res) => {
try {
const configs = await SystemConfig.findAll({
where: {
key: {
[Op.in]: [
"resend_api_key",
"mail_from",
"user_storage_quota",
"session_expire_hours",
],
},
},
});
const configMap = {};
configs.forEach((c) => (configMap[c.key] = c.value));
res.json(configMap);
} catch (error) {
console.error("이메일 설정 조회 오류:", error);
res.status(500).json({ error: "설정 조회 실패" });
}
});
/**
* Resend 설정 저장
* POST /api/admin/config/email
*/
router.post("/config/email", async (req, res) => {
try {
const {
resend_api_key,
mail_from,
user_storage_quota,
session_expire_hours,
} = req.body;
// 각 설정값 upsert (있으면 업데이트, 없으면 생성)
if (resend_api_key && !resend_api_key.includes("*"))
await SystemConfig.upsert({
key: "resend_api_key",
value: resend_api_key,
});
if (mail_from)
await SystemConfig.upsert({ key: "mail_from", value: mail_from });
if (user_storage_quota)
await SystemConfig.upsert({
key: "user_storage_quota",
value: user_storage_quota,
});
if (session_expire_hours)
await SystemConfig.upsert({
key: "session_expire_hours",
value: session_expire_hours,
});
res.json({ success: true });
} catch (error) {
console.error("이메일 설정 저장 오류:", error);
res.status(500).json({ error: "설정 저장 실패" });
}
});
/**
* Resend 연결 테스트 (테스트 이메일 발송)
* POST /api/admin/config/email/test
*/
router.post("/config/email/test", async (req, res) => {
try {
const { to, resend_api_key, mail_from } = req.body;
if (!resend_api_key) {
return res.status(400).json({ error: "Resend API 키가 필요합니다" });
}
// 입력된 값으로 테스트하기 위해 직접 Resend 클라이언트 생성
const { Resend } = require("resend");
const resend = new Resend(resend_api_key);
const result = await resend.emails.send({
from: mail_from || to,
to: [to],
subject: "Resend 연결 테스트",
html: "<h1>연결 성공!</h1><p>Resend 이메일 설정이 정상적으로 작동합니다.</p>",
text: "연결 성공! Resend 이메일 설정이 정상적으로 작동합니다.",
});
if (result.error) {
throw new Error(result.error.message);
}
res.json({ success: true });
} catch (error) {
console.error("Resend 테스트 오류:", error);
res.status(500).json({ error: error.message });
}
});
// ============================================================================
// API 라우트 - Gemini 설정
// ============================================================================
const geminiService = require("../services/geminiService");
/**
* Gemini 설정 조회
* GET /api/admin/config/gemini
*/
router.get("/config/gemini", async (req, res) => {
try {
const config = await geminiService.getGeminiConfig();
res.json(config);
} catch (error) {
console.error("Gemini 설정 조회 오류:", error);
res.status(500).json({ error: "Gemini 설정 조회 실패" });
}
});
/**
* Gemini 설정 저장
* POST /api/admin/config/gemini
*/
router.post("/config/gemini", async (req, res) => {
try {
const { gemini_api_key, gemini_model } = req.body;
await geminiService.saveGeminiConfig({ gemini_api_key, gemini_model });
res.json({ success: true });
} catch (error) {
console.error("Gemini 설정 저장 오류:", error);
res.status(500).json({ error: "Gemini 설정 저장 실패" });
}
});
/**
* Gemini 번역 API (일반 사용자도 사용 가능)
* POST /api/admin/translate
*/
router.post("/translate", async (req, res) => {
try {
const { text, targetLang } = req.body;
if (!text) {
return res.status(400).json({ error: "번역할 텍스트가 필요합니다" });
}
const config = await geminiService.getGeminiConfig();
if (!config.gemini_api_key) {
return res
.status(400)
.json({ error: "Gemini API 키가 설정되지 않았습니다" });
}
const translatedText = await geminiService.translateText(
text,
targetLang || "ko",
config.gemini_api_key,
config.gemini_model
);
res.json({ translatedText });
} catch (error) {
console.error("번역 오류:", error);
res.status(500).json({ error: error.message || "번역 실패" });
}
});
module.exports = router;

111
backend/routes/auth.js Normal file
View file

@ -0,0 +1,111 @@
/**
* 인증 라우터
* 로그인 토큰 검증
*/
const express = require("express");
const router = express.Router();
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { Op } = require("sequelize");
const User = require("../models/User");
const SystemConfig = require("../models/SystemConfig");
const JWT_SECRET =
process.env.JWT_SECRET || "super_secret_jwt_key_changed_in_production";
/**
* 로그인
* POST /api/login
* - 이메일 또는 아이디(@ 없이) 로그인 가능
*/
router.post("/login", async (req, res) => {
let { email, password } = req.body;
try {
// 아이디로 로그인 시도 시 (@ 없이 입력) 전체 이메일 조회
if (!email.includes("@")) {
const user = await User.findOne({
where: { email: { [Op.like]: `${email}@%` } },
});
if (user) email = user.email;
}
const user = await User.findOne({ where: { email } });
if (!user) {
return res.status(401).json({ error: "잘못된 자격 증명입니다." });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({ error: "잘못된 자격 증명입니다." });
}
// DB에서 세션 만료 시간 조회 (기본값: 24시간)
let expireHours = 24;
try {
const config = await SystemConfig.findByPk("session_expire_hours");
if (config && config.value) {
expireHours = parseInt(config.value) || 24;
}
} catch (e) {
console.error("세션 설정 조회 오류:", e);
}
// JWT 토큰 생성
console.log(`[로그인] ${user.email} - 세션 만료 시간: ${expireHours}시간`);
const token = jwt.sign(
{ id: user.id, email: user.email, isAdmin: user.isAdmin },
JWT_SECRET,
{ expiresIn: `${expireHours}h` }
);
res.json({
success: true,
token,
user: {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
},
});
} catch (error) {
console.error("로그인 오류:", error);
res.status(500).json({ error: "서버 내부 오류" });
}
});
/**
* 토큰 검증 최신 사용자 정보 조회
* GET /api/verify
* - 토큰 검증 DB에서 최신 사용자 정보 가져옴
*/
router.get("/verify", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ valid: false });
try {
const decoded = jwt.verify(token, JWT_SECRET);
// DB에서 최신 사용자 정보 조회
const user = await User.findByPk(decoded.id);
if (!user) {
return res
.status(401)
.json({ valid: false, error: "사용자를 찾을 수 없습니다" });
}
res.json({
valid: true,
user: {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
},
});
} catch (err) {
return res.status(401).json({ valid: false });
}
});
module.exports = router;

1089
backend/routes/mail.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
/**
* 이메일 발송 서비스 (Resend 사용)
* AWS SES 대신 Resend API를 사용하여 이메일 발송
*/
const { Resend } = require("resend");
/**
* Resend 클라이언트 생성
* DB에서 API 키를 가져와 설정
* @returns {Promise<{resend: Resend, from: string}>}
*/
const createClient = async () => {
const SystemConfig = require("../models/SystemConfig");
// DB에서 설정 조회
const configs = await SystemConfig.findAll();
const configMap = {};
configs.forEach((c) => (configMap[c.key] = c.value));
// DB 설정 우선, 없으면 환경 변수 사용
const apiKey = configMap["resend_api_key"] || process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error("Resend API 키가 설정되지 않았습니다");
}
const resend = new Resend(apiKey);
return {
resend,
from:
configMap["mail_from"] || process.env.MAIL_FROM || "admin@caadiq.co.kr",
};
};
/**
* 이메일 발송 (Resend 사용)
* @param {Object} params - 이메일 파라미터
* @param {string} [params.from] - 발신자 (선택, 없으면 기본값 사용)
* @param {string} params.to - 수신자
* @param {string} params.subject - 제목
* @param {string} params.html - HTML 본문
* @param {string} [params.text] - 텍스트 본문 (선택)
* @param {Array} [params.attachments] - 첨부파일 배열 (선택)
* @returns {Promise<Object>} Resend 발송 결과
*/
exports.sendEmail = async ({
from: senderEmail,
to,
subject,
html,
text,
attachments = [],
}) => {
try {
const { resend, from: defaultFrom } = await createClient();
// 발신자: 전달된 값 또는 기본값 사용
const actualFrom = senderEmail || defaultFrom;
// 첨부파일 변환 (Resend 형식: filename, content as Buffer/base64)
const resendAttachments = attachments.map((att) => ({
filename: att.filename,
content: Buffer.isBuffer(att.content)
? att.content
: Buffer.from(att.content, "base64"),
}));
console.log(`[Resend] 첨부파일 개수: ${attachments.length}`);
if (attachments.length > 0) {
console.log(
`[Resend] 첨부파일 정보:`,
attachments.map((a) => ({
filename: a.filename,
contentType: a.contentType,
contentLength: a.content?.length || 0,
}))
);
}
// to가 배열이면 그대로, 문자열이면 배열로 변환
const toArray = Array.isArray(to) ? to : to.split(",").map((e) => e.trim());
// html과 text 기본값 처리 (둘 다 없으면 빈 공백이라도 전달)
const finalHtml = html || text || " ";
const finalText = text || (html ? html.replace(/<[^>]*>/g, "") : " ");
const result = await resend.emails.send({
from: actualFrom,
to: toArray,
subject: subject || "(제목 없음)",
html: finalHtml,
text: finalText,
attachments: resendAttachments.length > 0 ? resendAttachments : undefined,
});
if (result.error) {
console.error("[Resend] 발송 오류:", result.error);
throw new Error(result.error.message);
}
console.log("[Resend] 메일 발송 완료:", result.data?.id);
return { messageId: result.data?.id };
} catch (error) {
console.error("[Resend] 발송 오류:", error);
throw error;
}
};

View file

@ -0,0 +1,139 @@
/**
* Gemini 번역 서비스
* @google/genai SDK를 사용한 이메일 번역
*/
const SystemConfig = require("../models/SystemConfig");
const GeminiModel = require("../models/GeminiModel");
/**
* DB에서 활성화된 Gemini 모델 목록 조회
*/
const listModels = async () => {
try {
const models = await GeminiModel.findAll({
where: { isActive: true },
order: [["sortOrder", "ASC"]],
attributes: ["modelId", "name", "description"],
});
return models.map((m) => ({
id: m.modelId,
name: m.name,
description: m.description || "",
}));
} catch (error) {
console.error("Gemini 모델 목록 조회 오류:", error);
// 폴백: 기본 모델 반환
return [
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", description: "" },
];
}
};
/**
* Gemini API로 텍스트 번역
* @param {string} text - 번역할 텍스트
* @param {string} targetLang - 대상 언어 (ko, en, ja, zh )
* @param {string} apiKey - Gemini API
* @param {string} model - 사용할 모델 ID
*/
const translateText = async (text, targetLang, apiKey, model) => {
if (!apiKey) {
throw new Error("Gemini API 키가 설정되지 않았습니다.");
}
const langNames = {
ko: "한국어",
en: "영어",
ja: "일본어",
zh: "중국어",
es: "스페인어",
fr: "프랑스어",
de: "독일어",
ru: "러시아어",
pt: "포르투갈어",
it: "이탈리아어",
};
const targetLangName = langNames[targetLang] || targetLang;
const selectedModel = model || "gemini-2.5-flash";
// ES Module 동적 import
const { GoogleGenAI } = await import("@google/genai");
// Google GenAI SDK 초기화
const ai = new GoogleGenAI({ apiKey });
// HTML 태그 보존하면서 번역하도록 프롬프트 작성
const prompt = `다음 이메일 내용을 ${targetLangName}로 번역해주세요. HTML 태그가 있다면 그대로 유지하고 텍스트만 번역해주세요. 번역만 출력하고 다른 설명은 하지 마세요.
${text}`;
try {
const response = await ai.models.generateContent({
model: selectedModel,
contents: prompt,
});
const translatedText = response.text;
if (!translatedText) {
throw new Error("번역 결과를 가져올 수 없습니다.");
}
return translatedText;
} catch (error) {
console.error("Gemini 번역 오류:", error);
throw new Error(error.message || "번역 중 오류가 발생했습니다.");
}
};
/**
* Gemini 설정 조회 (DB에서 모델 목록 가져오기)
*/
const getGeminiConfig = async () => {
const apiKeyRecord = await SystemConfig.findOne({
where: { key: "gemini_api_key" },
});
const modelRecord = await SystemConfig.findOne({
where: { key: "gemini_model" },
});
// DB에서 모델 목록 조회
const models = await listModels();
return {
gemini_api_key: apiKeyRecord?.value || "",
gemini_model: modelRecord?.value || "gemini-2.5-flash",
models,
};
};
/**
* Gemini 설정 저장
*/
const saveGeminiConfig = async (config) => {
// API 키가 마스킹된 값이 아닐 때만 저장 (********로 시작하지 않을 때)
if (
config.gemini_api_key !== undefined &&
!config.gemini_api_key.includes("*")
) {
await SystemConfig.upsert({
key: "gemini_api_key",
value: config.gemini_api_key,
});
}
if (config.gemini_model !== undefined) {
await SystemConfig.upsert({
key: "gemini_model",
value: config.gemini_model,
});
}
};
module.exports = {
translateText,
getGeminiConfig,
saveGeminiConfig,
listModels,
};

View file

@ -0,0 +1,150 @@
const getMailboxes = async (user, password) => {
return ["INBOX", "Sent", "Drafts", "Junk", "Trash", "Important"];
};
const mockEmails = [
{
id: "1",
seq: 1,
from: "Google <no-reply@accounts.google.com>",
subject: "새로운 기기에서 로그인됨",
date: new Date().toISOString(),
flags: [],
snippet:
"Windows 환경의 Chrome 브라우저에서 귀하의 Google 계정에 로그인했습니다. 본인의 활동이 맞다면 이 이메일을 무시하셔도 됩니다.",
},
{
id: "2",
seq: 2,
from: "Amazon Web Services <aws-marketing-email-replies@amazon.com>",
subject: "AWS Free Tier 사용량 알림",
date: new Date(Date.now() - 3600000).toISOString(),
flags: ["\\Seen"],
snippet:
"현재 AWS 프리 티어 사용량이 한도에 근접했습니다. 청구 대시보드에서 현재 사용량을 확인하고 예상 비용을 관리하세요.",
},
{
id: "3",
seq: 3,
from: "Slack <notification@slack.com>",
subject: "[Slack] 새로운 멘션이 있습니다",
date: new Date(Date.now() - 86400000).toISOString(),
flags: ["\\Seen", "\\Flagged"], // Starred
snippet:
"frontend-team 채널에서 @caadiq 님을 멘션했습니다: '이번 주 스프린트 계획 회의 시간 확인 부탁드립니다.'",
},
{
id: "4",
seq: 4,
from: "Github <noreply@github.com>",
subject: "Security alert for your repository",
date: new Date(Date.now() - 172800000).toISOString(),
flags: ["\\Seen"],
snippet:
"We found a potential security vulnerability in one of your dependencies. A Dependabot alert has been created.",
},
{
id: "5",
seq: 5,
from: "Notion <notify@mail.notion.so>",
subject: "12월 제품 업데이트 소식",
date: new Date(Date.now() - 259200000).toISOString(),
flags: [],
snippet:
"이번 달 업데이트: 새로운 데이터베이스 보기 옵션, 향상된 검색 기능, 그리고 모바일 앱 성능 개선사항을 확인해보세요.",
},
{
id: "6",
seq: 6,
from: "쿠팡 <noreply@coupang.com>",
subject: "주문하신 상품이 배송 완료되었습니다",
date: new Date(Date.now() - 432000000).toISOString(),
flags: ["\\Seen"],
snippet:
"고객님께서 주문하신 '맥북 프로 M3 Max' 상품이 문 앞에 배송되었습니다. 사진을 확인해주세요.",
},
{
id: "7",
seq: 7,
from: "Toss Team <support@toss.im>",
subject: "12월 카드 명세서가 도착했습니다",
date: new Date(Date.now() - 604800000).toISOString(),
flags: ["\\Seen"],
snippet:
"이번 달 총 사용 금액은 1,250,000원입니다. 결제일은 25일이며, 자세한 내역은 앱에서 확인하실 수 있습니다.",
},
];
const getEmails = async (user, password, boxName = "INBOX", limit = 20) => {
// Simulate network delay
// await new Promise((resolve) => setTimeout(resolve, 500));
let filtered = [...mockEmails];
// Simple Mock Filtering Logic
if (boxName === "Important") {
console.log("Fetching Important (Mock)");
// Return items that match the user's idea of 'Important' or just specific mock items
// In real IMAP we used FLAGGED. Let's filter by flag here too if we want realism,
// or just return specific ones.
// Let's rely on the mock data having flags.
return filtered.filter((e) => e.flags && e.flags.includes("\\Flagged"));
}
if (boxName === "Sent") {
return [
{
id: "101",
from: "Me <me@caadiq.co.kr>",
subject: "Re: 프로젝트 일정 공유",
date: new Date().toISOString(),
flags: ["\\Seen"],
snippet: "네, 확인했습니다. 말씀하신 대로 진행하겠습니다.",
},
];
}
if (boxName === "Drafts") {
return [
{
id: "201",
from: "Me <me@caadiq.co.kr>",
subject: "제안서 초안",
date: new Date().toISOString(),
flags: [],
snippet: "안녕하세요 대표님, 이번 프로젝트 제안서 초안 송부드립니다...",
},
];
}
if (boxName === "Junk" || boxName === "spam") {
return [];
}
if (boxName === "Trash") {
return [
{
id: "999",
from: "Spam <spam@spam.com>",
subject: "You won a lottery!",
date: new Date(Date.now() - 100000000).toISOString(),
flags: ["\\Seen"],
snippet: "Click here to claim your prize...",
},
];
}
// Default INBOX return
return filtered;
};
const connect = async () => {
// No-op for mock
return true;
};
module.exports = {
connect,
getMailboxes,
getEmails,
};

View file

@ -0,0 +1,174 @@
/**
* rspamd 스팸 필터 서비스
* 스팸 검사, 학습 API 연동
*/
const RSPAMD_HOST = process.env.RSPAMD_HOST || "rspamd";
const RSPAMD_PORT = process.env.RSPAMD_PORT || 11333;
const RSPAMD_BASE_URL = `http://${RSPAMD_HOST}:${RSPAMD_PORT}`;
// 스팸 점수 임계값 (이 값 이상이면 스팸으로 분류)
const SPAM_THRESHOLD = 6.0;
/**
* 이메일 스팸 검사
* @param {string} rawEmail - 원본 이메일 데이터 (RFC822 형식)
* @returns {Promise<{isSpam: boolean, score: number, action: string}>}
*/
const checkSpam = async (rawEmail) => {
try {
const response = await fetch(`${RSPAMD_BASE_URL}/checkv2`, {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: rawEmail,
});
if (!response.ok) {
console.error("[rspamd] 검사 실패:", response.status);
return { isSpam: false, score: 0, action: "no action" };
}
const result = await response.json();
// action: no action, greylist, add header, rewrite subject, soft reject, reject
const isSpam =
result.score >= SPAM_THRESHOLD ||
result.action === "reject" ||
result.action === "add header" ||
result.action === "rewrite subject";
console.log(
`[rspamd] 검사 결과: score=${result.score}, action=${result.action}, isSpam=${isSpam}`
);
return {
isSpam,
score: result.score || 0,
action: result.action || "no action",
symbols: result.symbols || {},
};
} catch (error) {
console.error("[rspamd] 검사 오류:", error.message);
// rspamd 연결 실패 시 스팸이 아닌 것으로 처리
return { isSpam: false, score: 0, action: "no action" };
}
};
/**
* 이메일을 스팸으로 학습
* @param {string} rawEmail - 원본 이메일 데이터
* @returns {Promise<boolean>}
*/
const learnSpam = async (rawEmail) => {
try {
const response = await fetch(`${RSPAMD_BASE_URL}/learnspam`, {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: rawEmail,
});
if (!response.ok) {
console.error("[rspamd] 스팸 학습 실패:", response.status);
return false;
}
const result = await response.json();
console.log("[rspamd] 스팸 학습 완료:", result);
return result.success !== false;
} catch (error) {
console.error("[rspamd] 스팸 학습 오류:", error.message);
return false;
}
};
/**
* 이메일을 정상 메일(ham) 학습
* @param {string} rawEmail - 원본 이메일 데이터
* @returns {Promise<boolean>}
*/
const learnHam = async (rawEmail) => {
try {
const response = await fetch(`${RSPAMD_BASE_URL}/learnham`, {
method: "POST",
headers: {
"Content-Type": "text/plain",
},
body: rawEmail,
});
if (!response.ok) {
console.error("[rspamd] 햄 학습 실패:", response.status);
return false;
}
const result = await response.json();
console.log("[rspamd] 햄 학습 완료:", result);
return result.success !== false;
} catch (error) {
console.error("[rspamd] 햄 학습 오류:", error.message);
return false;
}
};
/**
* rspamd 상태 확인
* @returns {Promise<boolean>}
*/
const checkHealth = async () => {
try {
const response = await fetch(`${RSPAMD_BASE_URL}/ping`, {
method: "GET",
});
return response.ok;
} catch {
return false;
}
};
/**
* 이메일 객체를 RFC822 형식으로 변환
* @param {Object} email - 이메일 객체 (from, to, subject, text, html)
* @returns {string}
*/
const buildRawEmail = (email) => {
const boundary = "----=_Part_" + Date.now();
let raw = "";
raw += `From: ${email.from || "unknown@unknown.com"}\r\n`;
raw += `To: ${email.to || "unknown@unknown.com"}\r\n`;
raw += `Subject: ${email.subject || "(no subject)"}\r\n`;
raw += `Date: ${
email.date ? new Date(email.date).toUTCString() : new Date().toUTCString()
}\r\n`;
raw += `Message-ID: ${email.messageId || `<${Date.now()}@local>`}\r\n`;
raw += `MIME-Version: 1.0\r\n`;
if (email.html) {
raw += `Content-Type: multipart/alternative; boundary="${boundary}"\r\n\r\n`;
raw += `--${boundary}\r\n`;
raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
raw += `${email.text || ""}\r\n`;
raw += `--${boundary}\r\n`;
raw += `Content-Type: text/html; charset=utf-8\r\n\r\n`;
raw += `${email.html}\r\n`;
raw += `--${boundary}--\r\n`;
} else {
raw += `Content-Type: text/plain; charset=utf-8\r\n\r\n`;
raw += `${email.text || ""}\r\n`;
}
return raw;
};
module.exports = {
checkSpam,
learnSpam,
learnHam,
checkHealth,
buildRawEmail,
SPAM_THRESHOLD,
};

View file

@ -0,0 +1,80 @@
/**
* S3 서비스 (Garage/MinIO 호환)
* 첨부파일 업로드 다운로드
*/
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
} = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
const crypto = require("crypto");
// S3 클라이언트 설정 (Garage/MinIO 호환 모드)
const s3Client = new S3Client({
region: process.env.AWS_REGION || "garage",
endpoint: process.env.MINIO_ENDPOINT || "http://garage:3900",
forcePathStyle: true,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
},
});
const BUCKET_NAME = process.env.MINIO_BUCKET || "emails";
/**
* 첨부파일 업로드
* @param {Buffer} buffer - 파일 내용
* @param {string} filename - 원본 파일명
* @param {string} mimeType - MIME 타입
* @returns {Promise<string>} S3 객체
*/
exports.uploadAttachment = async (buffer, filename, mimeType) => {
// 고유 키 생성: 타임스탬프_랜덤해시_파일명
const key = `${Date.now()}_${crypto
.randomBytes(8)
.toString("hex")}_${filename}`;
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
Body: buffer,
ContentType: mimeType,
})
);
return key;
};
/**
* 서명된 다운로드 URL 생성 (1시간 유효)
* @param {string} key - S3 객체
* @returns {Promise<string>} 서명된 URL
*/
exports.getAttachmentUrl = async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
return getSignedUrl(s3Client, command, { expiresIn: 3600 });
};
/**
* 객체 스트림 가져오기 (직접 다운로드용)
* @param {string} key - S3 객체
* @returns {Promise<{stream: ReadableStream, contentType: string, contentLength: number}>}
*/
exports.getObjectStream = async (key) => {
const command = new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: key,
});
const response = await s3Client.send(command);
return {
stream: response.Body,
contentType: response.ContentType,
contentLength: response.ContentLength,
};
};

View file

@ -0,0 +1,331 @@
/**
* SMTP 서비스
* 외부에서 들어오는 이메일을 수신하여 DB에 저장
* 접속 IP와 국가 정보를 SmtpLog에 기록
* rspamd를 통한 스팸 검사 지원
*/
const { SMTPServer } = require("smtp-server");
const { simpleParser } = require("mailparser");
const Inbox = require("../models/Inbox");
const Spam = require("../models/Spam");
const Sent = require("../models/Sent");
const SystemConfig = require("../models/SystemConfig");
const SmtpLog = require("../models/SmtpLog");
const { uploadAttachment } = require("./s3Service");
const rspamd = require("./rspamdService");
const { calculateEmailSize } = require("../utils/emailUtils");
/**
* IP 주소로 국가 정보 조회 (무료 GeoIP API 사용)
*/
const getCountryFromIP = async (ip) => {
try {
// 로컬/프라이빗 IP 처리
if (
ip === "::1" ||
ip === "127.0.0.1" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.") ||
ip.startsWith("172.")
) {
return { country: "LOCAL", countryName: "Local Network" };
}
// IPv6 매핑된 IPv4 주소 처리
const cleanIp = ip.replace(/^::ffff:/, "");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // 3초 타임아웃
const response = await fetch(
`http://ip-api.com/json/${cleanIp}?fields=status,country,countryCode`,
{
signal: controller.signal,
}
);
clearTimeout(timeout);
const data = await response.json();
if (data.status === "success") {
return { country: data.countryCode, countryName: data.country };
}
return { country: null, countryName: null };
} catch {
return { country: null, countryName: null };
}
};
/**
* 스토리지 쿼터 확인
*/
const checkStorageQuota = async () => {
try {
const quotaConfig = await SystemConfig.findOne({
where: { key: "user_storage_quota" },
});
const limitMB = quotaConfig ? parseInt(quotaConfig.value) : 51200;
const limitBytes = limitMB * 1024 * 1024;
const [inboxItems, sentItems] = await Promise.all([
Inbox.findAll({ attributes: ["attachments", "subject", "text", "html"] }),
Sent.findAll({ attributes: ["attachments", "subject", "text", "html"] }),
]);
let totalSize = 0;
const calculate = (items) => {
items.forEach((item) => {
if (item.subject) totalSize += Buffer.byteLength(item.subject, "utf8");
if (item.text) totalSize += Buffer.byteLength(item.text, "utf8");
if (item.html) totalSize += Buffer.byteLength(item.html, "utf8");
let atts = item.attachments;
if (typeof atts === "string") {
try {
atts = JSON.parse(atts);
if (typeof atts === "string") atts = JSON.parse(atts);
} catch {
atts = [];
}
}
if (Array.isArray(atts)) {
atts.forEach((a) => (totalSize += a.size || 0));
}
});
};
calculate(inboxItems);
calculate(sentItems);
return totalSize >= limitBytes;
} catch (error) {
console.error("[SMTP] 쿼터 확인 실패:", error);
return false;
}
};
// 허용된 도메인 목록 (수신 가능한 도메인)
const ALLOWED_DOMAINS = ["caadiq.co.kr"];
// SMTP 서버 설정
const server = new SMTPServer({
logger: true,
authOptional: true,
/**
* 수신자(RCPT TO) 검증
* 허용된 도메인으로 오는 메일만 수락하고, 외는 거부
*/
onRcptTo(address, session, callback) {
const recipientEmail = address.address.toLowerCase();
const recipientDomain = recipientEmail.split("@")[1];
// 허용된 도메인인지 확인
if (!recipientDomain || !ALLOWED_DOMAINS.includes(recipientDomain)) {
console.warn(
`[SMTP] 거부 - 허용되지 않은 도메인: ${recipientEmail} (도메인: ${recipientDomain})`
);
return callback(
new Error(`550 5.1.1 Recipient domain not allowed: ${recipientDomain}`)
);
}
console.log(`[SMTP] 수신자 승인: ${recipientEmail}`);
return callback();
},
async onMailFrom(address, session, callback) {
const isFull = await checkStorageQuota();
if (isFull) {
console.warn(`[SMTP] 저장 공간 부족으로 거부: ${address.address}`);
return callback(new Error("552 5.2.2 Storage quota exceeded"));
}
return callback();
},
onConnect(session, callback) {
const remoteAddress = session.remoteAddress || "unknown";
console.log(`[SMTP] 연결: ${remoteAddress}`);
session._remoteIp = remoteAddress;
callback();
},
onData(stream, session, callback) {
const fromAddr = session.envelope.mailFrom?.address || "unknown";
const toAddrs =
session.envelope.rcptTo?.map((r) => r.address).join(", ") || "unknown";
const remoteAddress =
session._remoteIp || session.remoteAddress || "unknown";
console.log(
`[SMTP] 메일 수신: ${fromAddr}${toAddrs} (from ${remoteAddress})`
);
// 원본 데이터 수집 (rspamd 검사용)
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", async () => {
const rawEmail = Buffer.concat(chunks).toString("utf8");
// 먼저 simpleParser로 파싱
let parsed;
try {
parsed = await simpleParser(rawEmail);
} catch (err) {
console.error("[SMTP] 파싱 오류:", err);
try {
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: false,
connectedAt: new Date(),
});
} catch {}
return callback(new Error("Message parse error"));
}
try {
// rspamd 스팸 검사
let spamResult = { isSpam: false, score: 0, action: "no action" };
try {
spamResult = await rspamd.checkSpam(rawEmail);
console.log(
`[SMTP] rspamd 검사: isSpam=${spamResult.isSpam}, score=${spamResult.score}`
);
} catch (rspamdError) {
console.warn(
"[SMTP] rspamd 검사 실패 (스팸 아님으로 처리):",
rspamdError.message
);
}
// 첨부파일 처리
const attachmentsData = [];
console.log(
`[SMTP] 파싱된 첨부파일 개수: ${parsed.attachments?.length || 0}`
);
if (parsed.attachments && parsed.attachments.length > 0) {
console.log(
`[SMTP] 첨부파일 정보:`,
parsed.attachments.map((a) => ({
filename: a.filename,
contentType: a.contentType,
size: a.size,
}))
);
for (const att of parsed.attachments) {
const key = await uploadAttachment(
att.content,
att.filename,
att.contentType
);
attachmentsData.push({
filename: att.filename,
contentType: att.contentType,
size: att.size,
key: key,
});
}
}
// 이메일 데이터 준비
// parsed.from 구조: { text: "이름 <email>", value: [{ name, address }] }
const fromValue = parsed.from?.value?.[0];
const fromEmail = fromValue?.address || fromAddr;
const fromName = fromValue?.name || null;
const emailData = {
from: fromEmail,
fromName: fromName,
to: parsed.to?.text || toAddrs,
subject: parsed.subject || "(제목 없음)",
text: parsed.text,
html: parsed.html || parsed.textAsHtml,
attachments: attachmentsData,
messageId: parsed.messageId,
date: parsed.date || new Date(),
isRead: false,
spamScore: spamResult.score,
rawEmail: rawEmail, // 원본 저장 (학습용)
};
// 메일 크기 계산
emailData.size = calculateEmailSize(emailData);
// 스팸 여부에 따라 저장 위치 결정
let email;
if (spamResult.isSpam) {
emailData.originalMailbox = "INBOX";
email = await Spam.create(emailData);
console.log(
`[SMTP] 스팸으로 분류: ${email.id} - ${email.subject} (score: ${spamResult.score})`
);
} else {
email = await Inbox.create(emailData);
console.log(`[SMTP] 저장 완료: ${email.id} - ${email.subject}`);
}
// 국가 정보 조회 및 접속 로그 기록
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
hostname: session.hostNameAppearsAs || null,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: true,
isSpam: spamResult.isSpam,
spamScore: spamResult.score,
connectedAt: new Date(),
});
// SSE를 통해 실시간 알림 전송 (스팸이 아닌 경우만)
if (!spamResult.isSpam) {
const sseService = require("./sseService");
sseService.notifyNewMail({
from: email.from,
to: email.to,
subject: email.subject,
date: email.date,
});
}
callback();
} catch (dbError) {
console.error("[SMTP] DB 오류:", dbError);
try {
const geoInfo = await getCountryFromIP(remoteAddress);
await SmtpLog.create({
remoteAddress,
country: geoInfo.country,
countryName: geoInfo.countryName,
mailFrom: fromAddr,
rcptTo: toAddrs,
success: false,
connectedAt: new Date(),
});
} catch {}
callback(new Error("Internal storage error"));
}
});
stream.on("error", (err) => {
console.error("[SMTP] 스트림 오류:", err);
callback(new Error("Stream error"));
});
},
});
exports.startSMTPServer = () => {
const port = 25;
server.listen(port, () => {
console.log(`[SMTP] 서버 시작: 포트 ${port}`);
});
server.on("error", (err) => {
console.error("[SMTP] 서버 오류:", err);
});
};

View file

@ -0,0 +1,48 @@
/**
* Server-Sent Events (SSE) 서비스
* 실시간 메일 수신 알림
*/
const clients = new Set();
/**
* 클라이언트 연결 추가
*/
exports.addClient = (res) => {
clients.add(res);
console.log(`[SSE] 클라이언트 연결: 총 ${clients.size}`);
};
/**
* 클라이언트 연결 제거
*/
exports.removeClient = (res) => {
clients.delete(res);
console.log(`[SSE] 클라이언트 연결 해제: 총 ${clients.size}`);
};
/**
* 모든 클라이언트에게 메일 알림 전송
*/
exports.notifyNewMail = (mailData) => {
const message = JSON.stringify({
type: "new-mail",
data: {
from: mailData.from,
to: mailData.to,
subject: mailData.subject,
date: mailData.date,
},
});
console.log(`[SSE] 새 메일 알림 전송: ${clients.size}명에게`);
clients.forEach((client) => {
try {
client.write(`data: ${message}\n\n`);
} catch (error) {
console.error("[SSE] 메시지 전송 실패:", error);
clients.delete(client);
}
});
};

67
backend/utils/dbInit.js Normal file
View file

@ -0,0 +1,67 @@
const sequelize = require("../config/database");
const Inbox = require("../models/Inbox");
const Sent = require("../models/Sent");
const Trash = require("../models/Trash");
const Spam = require("../models/Spam");
const Draft = require("../models/Draft");
const Important = require("../models/Important");
const User = require("../models/User");
const SystemConfig = require("../models/SystemConfig");
const SmtpLog = require("../models/SmtpLog");
const SentLog = require("../models/SentLog");
const GeminiModel = require("../models/GeminiModel");
const EmailTranslation = require("../models/EmailTranslation");
const mysql = require("mysql2/promise");
async function initializeDatabase() {
// 1. DB가 없으면 생성 (raw connection 사용)
try {
const connection = await mysql.createConnection({
host: process.env.DB_HOST || "mariadb",
user: process.env.DB_USER || "root",
password: process.env.DB_PASSWORD || "password",
});
const dbName = process.env.DB_NAME || "mail";
await connection.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`);
await connection.end();
console.log(`${dbName} 데이터베이스 확인/생성됨.`);
} catch (error) {
console.error("데이터베이스 생성 오류:", error);
// 에러를 던지지 않고 sequelize 연결 시도 (DB 생성 권한 없을 경우 대비)
}
// 2. 모델 동기화
try {
await sequelize.authenticate();
console.log("데이터베이스 연결 성공.");
// 테이블이 없을 때만 생성 (기존 테이블은 변경하지 않음)
// alter: true는 인덱스 중복 생성 문제를 일으킬 수 있으므로 사용하지 않음
await sequelize.sync({ force: false });
// 관리자 계정 시드
const adminExists = await User.findOne({ where: { isAdmin: true } });
if (!adminExists) {
const bcrypt = require("bcryptjs");
const hashedPrice = await bcrypt.hash("admin123", 10);
await User.create({
email: "admin@caadiq.co.kr",
password: hashedPrice,
name: "Administrator",
isAdmin: true,
});
console.log("관리자 계정 생성됨: admin@caadiq.co.kr / admin123");
}
// Gemini 모델 시드
await GeminiModel.seedDefaultModels();
console.log("데이터베이스 동기화 완료.");
} catch (error) {
console.error("데이터베이스 연결 실패:", error);
process.exit(1);
}
}
module.exports = initializeDatabase;

View file

@ -0,0 +1,36 @@
/**
* 메일 크기 계산 유틸리티
*/
/**
* 메일 크기 계산 (bytes)
* subject + text + html + 첨부파일 크기 합산
* @param {object} emailData - 메일 데이터
* @returns {number} 크기 (bytes)
*/
const calculateEmailSize = (emailData) => {
let size = 0;
if (emailData.subject) size += Buffer.byteLength(emailData.subject, "utf8");
if (emailData.text) size += Buffer.byteLength(emailData.text, "utf8");
if (emailData.html) size += Buffer.byteLength(emailData.html, "utf8");
let atts = emailData.attachments || [];
// JSON 문자열인 경우 파싱
if (typeof atts === "string") {
try {
atts = JSON.parse(atts);
} catch {
atts = [];
}
}
if (Array.isArray(atts)) {
atts.forEach((att) => {
size += att.size || 0;
});
}
return size;
};
module.exports = { calculateEmailSize };

204
backend/utils/helpers.js Normal file
View file

@ -0,0 +1,204 @@
/**
* 백엔드 공통 헬퍼 함수
* mail.js, admin.js 등에서 공통으로 사용되는 유틸리티
*/
const Inbox = require("../models/Inbox");
const Sent = require("../models/Sent");
const Trash = require("../models/Trash");
const Spam = require("../models/Spam");
const Draft = require("../models/Draft");
const Important = require("../models/Important");
// 메일함 이름으로 Sequelize 모델 반환 맵
const MAILBOX_MODELS = {
INBOX: Inbox,
SENT: Sent,
TRASH: Trash,
SPAM: Spam,
DRAFTS: Draft,
IMPORTANT: Important,
};
/**
* 메일함 이름으로 Sequelize 모델 반환
* @param {string} mailboxName - 메일함 이름 (INBOX, SENT, TRASH )
* @returns {Model} Sequelize 모델
*/
const getModel = (mailboxName = "INBOX") => {
return MAILBOX_MODELS[mailboxName.toUpperCase()] || Inbox;
};
/**
* 이메일 데이터 정규화 (프론트엔드 호환성)
* @param {Object} email - 이메일 객체
* @param {string} mailboxName - 메일함 이름
* @returns {Object} 정규화된 이메일 데이터
*/
const normalizeEmail = (email, mailboxName) => {
if (!email) return null;
const data = email.toJSON ? email.toJSON() : email;
data.mailbox = mailboxName.toUpperCase();
// 보낸편지함 호환성: body 필드가 있고 html이 없으면 body를 html로 복사
if (data.body && !data.html) {
data.html = data.body;
}
return data;
};
/**
* 스토리지 사용량 계산 (본문 + 첨부파일 크기)
* mail.js와 admin.js에서 동일하게 사용
* @param {Array} items - 메일 항목 배열
* @returns {number} 바이트 크기
*/
const calculateStorageSize = (items) => {
let totalSize = 0;
items.forEach((item) => {
// 본문/제목 크기
if (item.subject) totalSize += Buffer.byteLength(item.subject, "utf8");
if (item.text) totalSize += Buffer.byteLength(item.text, "utf8");
if (item.html) totalSize += Buffer.byteLength(item.html, "utf8");
// 첨부파일 크기
let atts = item.attachments;
if (typeof atts === "string") {
try {
atts = JSON.parse(atts);
if (typeof atts === "string") atts = JSON.parse(atts);
} catch {
atts = [];
}
}
if (Array.isArray(atts)) {
atts.forEach((att) => (totalSize += att.size || 0));
}
});
return totalSize;
};
/**
* 첨부파일 배열 파싱 (JSON 문자열 처리)
* @param {Array|string} attachments - 첨부파일 데이터
* @returns {Array} 파싱된 첨부파일 배열
*/
const parseAttachments = (attachments) => {
if (!attachments) return [];
if (Array.isArray(attachments)) return attachments;
if (typeof attachments === "string") {
try {
let parsed = JSON.parse(attachments);
// 이중 JSON 문자열 처리
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
/**
* 안전한 트랜잭션 롤백 헬퍼
* 연결이 끊어진 경우에도 서버 크래시 방지
* @param {Transaction} transaction - Sequelize 트랜잭션
*/
const safeRollback = async (transaction) => {
try {
await transaction.rollback();
} catch (rollbackError) {
console.error("롤백 오류 (무시됨):", rollbackError.message);
}
};
/**
* 이메일 문자열에서 순수 이메일 주소만 추출
* "Name" <email@domain.com> 또는 email@domain.com 형태 모두 지원
* @param {string} emailStr - 이메일 문자열
* @returns {string} 순수 이메일 주소
*/
const extractEmailAddress = (emailStr) => {
if (!emailStr) return "";
// <email> 형태에서 추출
const match = emailStr.match(/<([^>]+)>/);
if (match) return match[1].toLowerCase();
// 그냥 이메일만 있는 경우
return emailStr.trim().toLowerCase();
};
/**
* 이메일 조회 (소유권 검증 포함)
* @param {number} id - 이메일 ID
* @param {string} userEmail - 사용자 이메일
* @param {string} mailboxContext - 메일함 컨텍스트 (선택)
* @returns {Promise<Object|null>} { email, Model, box } 또는 null
*/
const findEmail = async (id, userEmail, mailboxContext) => {
const userEmailLower = userEmail.toLowerCase();
// 메일함이 명시된 경우 해당 모델에서 직접 조회
if (mailboxContext) {
const Model = getModel(mailboxContext);
const email = await Model.findByPk(id);
if (email) {
// from 필드에서 이메일 주소만 추출하여 비교
const fromEmail = extractEmailAddress(email.from);
const toContains =
email.to && email.to.toLowerCase().includes(userEmailLower);
const isOwner = fromEmail === userEmailLower || toContains;
if (isOwner) return { email, Model, box: mailboxContext };
}
return null;
}
// 폴백: 모든 메일함 순회 (mailboxContext 없이 호출 시)
const boxList = ["INBOX", "SENT", "TRASH", "SPAM", "DRAFTS", "IMPORTANT"];
for (const box of boxList) {
const Model = getModel(box);
const email = await Model.findByPk(id);
if (email) {
const fromEmail = extractEmailAddress(email.from);
const toContains =
email.to && email.to.toLowerCase().includes(userEmailLower);
const isOwner = fromEmail === userEmailLower || toContains;
if (isOwner) return { email, Model, box };
}
}
return null;
};
/**
* 기간 시작일 계산 (1d, 7d, 30d, all)
* @param {string} period - 기간 문자열
* @returns {Date|null} 시작일 또는 null (전체 기간)
*/
const getPeriodStartDate = (period) => {
const periodStart = new Date();
switch (period) {
case "1d":
periodStart.setDate(periodStart.getDate() - 1);
return periodStart;
case "7d":
periodStart.setDate(periodStart.getDate() - 7);
return periodStart;
case "30d":
periodStart.setDate(periodStart.getDate() - 30);
return periodStart;
default:
return null;
}
};
module.exports = {
getModel,
normalizeEmail,
calculateStorageSize,
parseAttachments,
safeRollback,
findEmail,
getPeriodStartDate,
extractEmailAddress,
MAILBOX_MODELS,
};

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
services:
backend:
build: ./backend
container_name: email-backend
ports:
- "25:25"
volumes:
- ./backend:/app
- /app/node_modules
env_file:
- .env
environment:
RSPAMD_HOST: rspamd
RSPAMD_PORT: 11333
depends_on:
- rspamd
networks:
- app
- db
restart: unless-stopped
frontend:
build: ./frontend
container_name: email-frontend
depends_on:
- backend
networks:
- app
restart: unless-stopped
rspamd:
image: rspamd/rspamd:latest
container_name: email-rspamd
volumes:
- ./rspamd/local.d:/etc/rspamd/local.d:ro
- rspamd-data:/var/lib/rspamd
depends_on:
- redis
networks:
- app
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: email-redis
volumes:
- redis-data:/data
networks:
- app
restart: unless-stopped
networks:
app:
external: true
db:
external: true
volumes:
rspamd-data:
redis-data:

25
frontend/Dockerfile Normal file
View file

@ -0,0 +1,25 @@
# 빌드 스테이지
FROM node:18-alpine AS build
WORKDIR /app
# 패키지 설치
COPY package*.json ./
RUN npm ci
# 소스 복사 및 빌드
COPY . .
RUN npm run build
# 프로덕션 스테이지
FROM nginx:alpine
# nginx 설정 복사
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 빌드된 파일 복사
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

794
frontend/dist/assets/index-4CluA2BD.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

20
frontend/dist/index.html vendored Normal file
View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
as="style"
crossorigin="anonymous"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css"
/>
<title>Mailbox</title>
<script type="module" crossorigin src="/assets/index-4CluA2BD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-5J-UkslY.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

19
frontend/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
as="style"
crossorigin="anonymous"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.8/dist/web/static/pretendard.css"
/>
<title>Mailbox</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

39
frontend/nginx.conf Normal file
View file

@ -0,0 +1,39 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# gzip 압축
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
# 정적 파일 캐싱
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 프록시
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
# SSE 지원
proxy_buffering off;
proxy_read_timeout 86400s;
}
# SPA 라우팅 지원
location / {
try_files $uri $uri/ /index.html;
}
}

6259
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
frontend/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "email-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-datepicker": "^8.10.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^7.10.1",
"recharts": "^3.5.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.0"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#6366f1"/>
<stop offset="100%" style="stop-color:#4f46e5"/>
</linearGradient>
</defs>
<!-- 둥근 배경 -->
<rect x="5" y="5" width="90" height="90" rx="22" fill="url(#bg)"/>
<!-- 편지 봉투 외곽선 -->
<rect x="20" y="32" width="60" height="40" rx="5" fill="none" stroke="white" stroke-width="5"/>
<!-- 봉투 뚜껑 (V자) -->
<path d="M22 35 L50 55 L78 35" fill="none" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 669 B

87
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,87 @@
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useMail, MailProvider } from './context/MailContext';
import Login from './components/Login';
import Dashboard from './components/Dashboard';
import AdminPage from './components/admin/AdminPage';
function RequireAuth({ children }) {
const { user } = useMail();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function AppContent() {
const { user, initialLoading } = useMail();
if (initialLoading) {
return <div className="h-screen flex items-center justify-center bg-white">Loading...</div>;
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
{/* 관리자 라우트 가드 */}
<Route path="/admin/*" element={
<RequireAuth>
{user?.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
</RequireAuth>
} />
{/* 메일 라우트 (소문자 기본) */}
<Route path="/mail/:mailbox" element={<RequireAuth><Dashboard /></RequireAuth>} />
<Route path="/mail/:mailbox/:emailId" element={<RequireAuth><Dashboard /></RequireAuth>} />
{/* 루트 경로 리다이렉트 (소문자 inbox) */}
<Route path="/" element={<Navigate to="/mail/inbox" replace />} />
{/* 404 폴백 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}
import { Toaster } from 'react-hot-toast';
function App() {
return (
<MailProvider>
<AppContent />
<Toaster
position="bottom-right"
toastOptions={{
duration: 4000,
style: {
background: '#333',
color: '#fff',
borderRadius: '8px',
padding: '12px 16px',
fontSize: '14px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
},
success: {
iconTheme: {
primary: '#4ade80',
secondary: '#333',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#333',
},
},
}}
/>
</MailProvider>
)
}
export default App

View file

@ -0,0 +1,875 @@
/**
* 메일 작성 모달 컴포넌트
* 수신자, 제목, 본문 입력 발송
*/
import React, { useState, useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useMail } from '../context/MailContext';
import { X, Minimize2, Maximize2, Paperclip, Image as ImageIcon, Link as LinkIcon, Smile, Minus, Send, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List, ListOrdered } from 'lucide-react';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Fade from '@mui/material/Fade';
const ComposeModal = ({ isOpen, onClose, draftData, mode = 'compose', replyData = null }) => {
const { sendEmail, saveDraft, deleteDraft, handleDraftSendComplete } = useMail();
const [recipients, setRecipients] = useState([]);
const [inputValue, setInputValue] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [isMinimized, setIsMinimized] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const [isSending, setIsSending] = useState(false);
const [attachments, setAttachments] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [linkText, setLinkText] = useState('');
const [linkUrl, setLinkUrl] = useState('');
const [showFormatBar, setShowFormatBar] = useState(false);
const [activeFormats, setActiveFormats] = useState({ bold: false, italic: false, underline: false });
const [userList, setUserList] = useState([]);
const [showUserSuggestions, setShowUserSuggestions] = useState(false);
const [filteredUsers, setFilteredUsers] = useState([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [currentDraftId, setCurrentDraftId] = useState(null);
const subjectInputRef = useRef(null);
const fileInputRef = useRef(null);
const recipientInputRef = useRef(null);
const bodyRef = useRef(null);
const suggestionListRef = useRef(null);
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email.trim());
};
const handleSend = async () => {
let finalRecipients = [...recipients];
if (inputValue.trim()) {
finalRecipients.push(inputValue.trim());
}
if (finalRecipients.length === 0) return toast.error('받는 사람을 입력하세요');
const invalidEmails = finalRecipients.filter(email => !validateEmail(email));
if (invalidEmails.length > 0) {
return toast.error(`올바르지 않은 이메일이 있습니다: ${invalidEmails.join(', ')}`);
}
// ID
const draftIdToDelete = currentDraftId;
setIsSending(true);
try {
await sendEmail(finalRecipients, subject, body, attachments);
toast.success('메일이 발송되었습니다.');
// MailContext
if (draftIdToDelete) {
await handleDraftSendComplete(draftIdToDelete);
}
closeAndReset();
} catch (e) {
toast.error('메일 발송 실패: ' + e.message);
} finally {
setIsSending(false);
}
};
const handleInputChange = (e) => {
const value = e.target.value;
setInputValue(value);
//
if (value.trim()) {
const filtered = userList.filter(user =>
user.email.toLowerCase().includes(value.toLowerCase()) ||
user.name?.toLowerCase().includes(value.toLowerCase())
);
setFilteredUsers(filtered);
setShowUserSuggestions(filtered.length > 0);
setSelectedSuggestionIndex(-1); //
} else {
setShowUserSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
const selectUser = (userEmail) => {
//
if (!recipients.includes(userEmail)) {
setRecipients([...recipients, userEmail]);
}
setInputValue('');
setShowUserSuggestions(false);
};
const handleKeyDown = (e) => {
//
if (showUserSuggestions && filteredUsers.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
//
if (selectedSuggestionIndex < filteredUsers.length - 1) {
const newIndex = selectedSuggestionIndex + 1;
setSelectedSuggestionIndex(newIndex);
//
setTimeout(() => {
const item = suggestionListRef.current?.children[newIndex];
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 0);
}
return;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
//
if (selectedSuggestionIndex > 0) {
const newIndex = selectedSuggestionIndex - 1;
setSelectedSuggestionIndex(newIndex);
//
setTimeout(() => {
const item = suggestionListRef.current?.children[newIndex];
item?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, 0);
}
return;
} else if (e.key === 'Enter' && selectedSuggestionIndex >= 0) {
e.preventDefault();
selectUser(filteredUsers[selectedSuggestionIndex].email);
setSelectedSuggestionIndex(-1);
return;
}
}
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addRecipient();
setShowUserSuggestions(false);
setSelectedSuggestionIndex(-1);
} else if (e.key === 'Backspace' && inputValue === '' && recipients.length > 0) {
setRecipients(recipients.slice(0, -1));
} else if (e.key === 'Tab') {
e.preventDefault();
if (inputValue.trim()) {
addRecipient();
}
setShowUserSuggestions(false);
setSelectedSuggestionIndex(-1);
setTimeout(() => {
subjectInputRef.current?.focus();
}, 0);
} else if (e.key === 'Escape') {
setShowUserSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
const addRecipient = () => {
const email = inputValue.trim();
if (email) {
if (validateEmail(email)) {
if (!recipients.includes(email)) {
setRecipients([...recipients, email]);
}
setInputValue('');
} else {
toast.error('올바른 이메일 형식이 아닙니다.');
}
}
};
const handleBlur = () => {
// addRecipient(); // Removed as Enter/Tab/Comma handle this now
setShowUserSuggestions(false);
};
const removeRecipient = (index) => {
setRecipients(prev => prev.filter((_, i) => i !== index));
};
// Base64 ( 4/3)
const calculateBase64Size = (originalSize) => Math.ceil(originalSize / 3) * 4;
// Base64
const getCurrentTotalBase64Size = () => {
return attachments.reduce((total, file) => total + calculateBase64Size(file.size), 0);
};
const handleFileSelect = (e) => {
const files = Array.from(e.target.files || []);
const MAX_BASE64_SIZE = 40 * 1024 * 1024; // 40MB (Base64 )
let currentTotal = getCurrentTotalBase64Size();
const validFiles = [];
const invalidFiles = [];
files.forEach(file => {
const base64Size = calculateBase64Size(file.size);
if (currentTotal + base64Size > MAX_BASE64_SIZE) {
invalidFiles.push(file.name);
} else {
validFiles.push(file);
currentTotal += base64Size;
}
});
if (invalidFiles.length > 0) {
const remaining = Math.max(0, (MAX_BASE64_SIZE - getCurrentTotalBase64Size()) * 0.75); //
toast.error(`첨부파일 용량 초과: ${invalidFiles.join(', ')} (남은 용량: ${(remaining / 1024 / 1024).toFixed(1)}MB)`);
}
if (validFiles.length > 0) {
setAttachments(prev => [...prev, ...validFiles]);
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const insertHtmlAtCursor = (html) => {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
range.deleteContents();
const fragment = range.createContextualFragment(html);
range.insertNode(fragment);
range.collapse(false);
}
bodyRef.current?.focus();
};
const handleFileButtonClick = () => {
fileInputRef.current?.click();
};
const removeAttachment = (index) => {
setAttachments(prev => prev.filter((_, i) => i !== index));
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files || []);
const MAX_BASE64_SIZE = 40 * 1024 * 1024; // 40MB (Base64 )
let currentTotal = getCurrentTotalBase64Size();
const validFiles = [];
const invalidFiles = [];
files.forEach(file => {
const base64Size = calculateBase64Size(file.size);
if (currentTotal + base64Size > MAX_BASE64_SIZE) {
invalidFiles.push(file.name);
} else {
validFiles.push(file);
currentTotal += base64Size;
}
});
if (invalidFiles.length > 0) {
const remaining = Math.max(0, (MAX_BASE64_SIZE - getCurrentTotalBase64Size()) * 0.75);
toast.error(`첨부파일 용량 초과: ${invalidFiles.join(', ')} (남은 용량: ${(remaining / 1024 / 1024).toFixed(1)}MB)`);
}
if (validFiles.length > 0) {
setAttachments(prev => [...prev, ...validFiles]);
}
};
const applyFormat = (command) => {
bodyRef.current?.focus();
document.execCommand(command, false, null);
updateFormatState();
};
const updateFormatState = () => {
setActiveFormats({
bold: document.queryCommandState('bold'),
italic: document.queryCommandState('italic'),
underline: document.queryCommandState('underline')
});
};
const handleInsertLink = () => {
const selection = window.getSelection();
const selectedText = selection.toString();
setLinkText(selectedText || '');
setShowLinkDialog(true);
};
const insertLink = () => {
if (!linkUrl) {
toast.error('URL을 입력하세요');
return;
}
const displayText = linkText || linkUrl;
const link = `<a href="${linkUrl}" target="_blank" style="color: #2563eb; text-decoration: underline;">${displayText}</a>`;
insertHtmlAtCursor(link);
setShowLinkDialog(false);
setLinkText('');
setLinkUrl('');
};
const toggleMinimize = () => setIsMinimized(!isMinimized);
const toggleMaximize = () => setIsMaximized(!isMaximized);
const formStateRef = useRef({ recipients: [], inputValue: '', subject: '', body: '' });
// ( )
useEffect(() => {
if (isOpen) {
const fetchUsers = async () => {
try {
const token = localStorage.getItem('email_token');
const res = await fetch('/api/emails?mailbox=INBOX&page=1&limit=100', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
const uniqueEmails = new Set();
data.emails.forEach(email => {
const emailMatch = email.from.match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/);
if (emailMatch) {
uniqueEmails.add(emailMatch[1]);
}
});
const users = Array.from(uniqueEmails).map(email => ({
email: email,
name: email.split('@')[0]
}));
setUserList(users);
}
} catch (error) {
console.error('사용자 목록 조회 오류:', error);
}
};
fetchUsers();
}
}, [isOpen]);
useEffect(() => {
formStateRef.current = { recipients, inputValue, subject, body };
}, [recipients, inputValue, subject, body]);
useEffect(() => {
if (isOpen && bodyRef.current && !bodyRef.current.innerHTML) {
bodyRef.current.innerHTML = body;
}
}, [isOpen, body]);
useEffect(() => {
if (isOpen) {
window.history.pushState({ modal: 'compose' }, '', window.location.href);
const handlePopState = (event) => {
if (event.state?.modal === 'compose') {
return;
}
handleClose();
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}
}, [isOpen]);
//
useEffect(() => {
if (isOpen && draftData) {
//
if (draftData.to) {
const toList = draftData.to.split(',').map(e => e.trim()).filter(e => e);
setRecipients(toList);
}
// '( )'
const loadedSubject = draftData.subject === '(제목 없음)' ? '' : (draftData.subject || '');
setSubject(loadedSubject);
setBody(draftData.html || draftData.text || '');
if (bodyRef.current) {
bodyRef.current.innerHTML = draftData.html || draftData.text || '';
}
// ID
setCurrentDraftId(draftData.id || null);
} else if (isOpen && !draftData) {
// ID
setCurrentDraftId(null);
}
}, [isOpen, draftData]);
// /
useEffect(() => {
if (isOpen && replyData && (mode === 'reply' || mode === 'forward')) {
//
const extractEmail = (fromStr) => {
if (!fromStr) return '';
const match = fromStr.match(/<([^>]+)>/);
return match ? match[1] : fromStr.split('<')[0].trim();
};
//
if (mode === 'reply') {
const replyTo = extractEmail(replyData.from);
if (replyTo) setRecipients([replyTo]);
}
//
const originalSubject = replyData.subject || '';
if (mode === 'reply') {
setSubject(originalSubject.startsWith('Re:') ? originalSubject : `Re: ${originalSubject}`);
} else {
setSubject(originalSubject.startsWith('Fwd:') ? originalSubject : `Fwd: ${originalSubject}`);
}
//
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString('ko-KR', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
const originalFrom = replyData.from || '';
const originalDate = formatDate(replyData.date);
const originalBody = replyData.html || replyData.text || '';
const quotedBody = `
<br/><br/>
<div style="border-left: 2px solid #ccc; padding-left: 12px; margin-left: 8px; color: #666;">
<p style="margin: 0 0 8px 0; font-size: 12px; color: #888;">
${originalDate}, ${originalFrom} 님이 작성:
</p>
${originalBody}
</div>
`;
setBody(quotedBody);
if (bodyRef.current) {
bodyRef.current.innerHTML = quotedBody;
}
//
if (mode === 'forward' && replyData.attachments && replyData.attachments.length > 0) {
const fetchAttachments = async () => {
const token = localStorage.getItem('email_token');
const forwardedFiles = [];
for (const att of replyData.attachments) {
try {
// (mailbox )
const mailbox = replyData.mailbox || 'INBOX';
const encodedFilename = encodeURIComponent(att.filename);
const response = await fetch(`/api/emails/${replyData.id}/attachments/${encodedFilename}?mailbox=${mailbox}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const blob = await response.blob();
// Blob File
const file = new File([blob], att.filename, { type: att.contentType || blob.type });
forwardedFiles.push(file);
}
} catch (error) {
console.error(`첨부파일 가져오기 실패: ${att.filename}`, error);
}
}
if (forwardedFiles.length > 0) {
setAttachments(prev => [...prev, ...forwardedFiles]);
}
};
fetchAttachments();
}
}
}, [isOpen, replyData, mode]);
const handleClose = async () => {
const { recipients: r, inputValue: iv, subject: s, body: b } = formStateRef.current;
const hasContent = r.length > 0 || iv.trim() || s.trim() || b.trim();
if (hasContent) {
//
try {
const allRecipients = [...r];
if (iv.trim()) allRecipients.push(iv.trim());
// ID
if (currentDraftId) {
await deleteDraft(currentDraftId);
}
await saveDraft(allRecipients, s, b);
toast.success('임시 보관함에 저장되었습니다', {
icon: '📝',
duration: 3000,
});
} catch (err) {
console.error('임시저장 오류:', err);
toast.error('임시저장에 실패했습니다');
}
}
closeAndReset();
};
const closeAndReset = () => {
setRecipients([]);
setInputValue('');
setSubject('');
setBody('');
setAttachments([]);
setIsMaximized(false);
setCurrentDraftId(null);
onClose();
};
if (!isOpen) return null;
const containerClasses = isMinimized
? "fixed bottom-0 right-10 w-72 h-[52px] rounded-t-lg shadow-2xl z-50 flex flex-col bg-gradient-to-r from-slate-700 to-slate-800 transition-all duration-300"
: isMaximized
? "fixed bottom-10 right-10 w-[calc(100vw-5rem)] h-[calc(100vh-5rem)] rounded-xl shadow-2xl z-50 flex flex-col bg-white transition-all duration-300"
: "fixed bottom-10 right-10 w-[600px] h-[680px] rounded-xl shadow-2xl z-50 flex flex-col bg-white transition-all duration-300";
return (
<div className={containerClasses}>
{/* 헤더 */}
<div
className={`flex-none px-5 py-3 flex items-center justify-between cursor-pointer select-none transition-all ${
isMinimized
? 'bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-600 hover:to-slate-700 rounded-t-lg'
: 'bg-gradient-to-r from-blue-500 to-indigo-600 rounded-t-xl'
}`}
onClick={isMinimized ? toggleMinimize : undefined}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Send className="h-5 w-5 text-white flex-shrink-0" />
<span className="font-semibold text-white truncate">
{isMinimized && recipients.length > 0
? `${mode === 'reply' ? '답장' : mode === 'forward' ? '전달' : '새 메일'} - ${recipients[0]}${recipients.length > 1 ? `${recipients.length - 1}` : ''}`
: mode === 'reply' ? '답장' : mode === 'forward' ? '전달' : '새 메일'
}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Tooltip title="최소화" placement="bottom" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
toggleMinimize();
}}
>
<Minus className={`h-4 w-4 ${isMinimized ? 'text-white' : 'text-white/80 hover:text-white'}`} />
</IconButton>
</Tooltip>
<Tooltip title={isMaximized && !isMinimized ? '복원' : '최대화'} placement="bottom" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
if (isMinimized) toggleMinimize();
else toggleMaximize();
}}
>
{isMaximized && !isMinimized ? (
<Minimize2 className={`h-4 w-4 ${isMinimized ? 'text-white' : 'text-white/80 hover:text-white'}`} />
) : (
<Maximize2 className={`h-4 w-4 ${isMinimized ? 'text-white' : 'text-white/80 hover:text-white'}`} />
)}
</IconButton>
</Tooltip>
<Tooltip title="닫기" placement="bottom" TransitionComponent={Fade}>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); handleClose(); }}>
<X className={`h-4 w-4 ${isMinimized ? 'text-white' : 'text-white/80 hover:text-white'}`} />
</IconButton>
</Tooltip>
</div>
</div>
{/* 콘텐츠 래퍼 */}
<div className={`flex-1 flex flex-col transition-opacity duration-200 ${isMinimized ? 'opacity-0 pointer-events-none' : 'opacity-100'} relative`}>
{/* 링크 삽입 다이얼로그 */}
{showLinkDialog && (
<div className="absolute inset-0 z-50 bg-white/95 flex items-center justify-center p-8 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 max-w-md w-full">
<h3 className="font-bold text-gray-800 text-lg mb-4">링크 삽입</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">표시 텍스트</label>
<input
type="text"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
placeholder="링크 텍스트"
className="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">URL</label>
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => { setShowLinkDialog(false); setLinkText(''); setLinkUrl(''); }}
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 font-medium text-sm"
>
취소
</button>
<button
onClick={insertLink}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium text-sm"
>
삽입
</button>
</div>
</div>
</div>
)}
{/* 입력 폼 */}
<div className="flex-1 flex flex-col">
{/* 받는사람 */}
<div className="relative flex items-center border-b border-gray-100 px-5 py-3">
<label className="w-16 flex-shrink-0 text-sm text-gray-600">받는사람</label>
<div className="flex-1">
<div className="flex flex-wrap gap-2 items-center">
{recipients.map((email, index) => (
<div key={index} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">
<span>{email}</span>
<button onClick={() => removeRecipient(index)} className="hover:bg-blue-200 rounded-full p-0.5">
<X size={14} />
</button>
</div>
))}
<input
ref={recipientInputRef}
type="text"
className="flex-1 min-w-[200px] py-1 outline-none text-gray-800 text-sm placeholder-gray-400"
autoFocus={!isMinimized}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={() => {
if (inputValue.trim() && filteredUsers.length > 0) {
setShowUserSuggestions(true);
}
}}
placeholder="이메일 주소 입력 (Tab으로 다음)"
/>
</div>
{/* 자동완성 드롭다운 */}
{showUserSuggestions && (
<div ref={suggestionListRef} className="absolute left-16 right-5 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50">
{filteredUsers.map((user, index) => (
<div
key={index}
onMouseDown={(e) => {
e.preventDefault(); // blur
selectUser(user.email);
}}
onMouseEnter={() => setSelectedSuggestionIndex(index)}
className={`px-4 py-2 cursor-pointer flex items-center justify-between transition-colors ${
selectedSuggestionIndex === index
? 'bg-blue-100 text-blue-700'
: 'hover:bg-blue-50'
}`}
>
<div>
<div className={`text-sm font-medium ${selectedSuggestionIndex === index ? 'text-blue-700' : 'text-gray-800'}`}>{user.email}</div>
{user.name && <div className={`text-xs ${selectedSuggestionIndex === index ? 'text-blue-500' : 'text-gray-500'}`}>{user.name}</div>}
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 제목 */}
<div className="flex items-center px-5 py-3 border-b border-gray-100">
<span className="text-gray-400 text-sm font-medium flex-shrink-0" style={{width: '60px'}}>제목</span>
<input
ref={subjectInputRef}
type="text"
className="flex-1 py-1 outline-none text-gray-800 text-sm placeholder-gray-400 ml-1"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="메일 제목을 입력하세요"
/>
</div>
{/* 본문 */}
<div
ref={bodyRef}
contentEditable
className="flex-1 p-5 outline-none resize-none text-gray-800 text-sm leading-relaxed overflow-y-auto overflow-x-hidden"
onInput={(e) => setBody(e.currentTarget.innerHTML)}
onMouseUp={updateFormatState}
onKeyUp={updateFormatState}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
suppressContentEditableWarning
data-placeholder="내용을 입력하세요..."
style={{
minHeight: '200px',
maxWidth: '100%',
wordWrap: 'break-word'
}}
/>
{/* 드래그 오버레이 */}
{isDragging && (
<div className="absolute inset-0 bg-blue-50/90 border-2 border-dashed border-blue-400 flex items-center justify-center pointer-events-none">
<div className="text-center">
<Paperclip size={48} className="mx-auto mb-2 text-blue-500" />
<p className="text-blue-600 font-semibold">파일을 여기에 놓으세요</p>
</div>
</div>
)}
{/* 첨부파일 목록 */}
{attachments.length > 0 && (
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50">
<div className="flex flex-wrap gap-2">
{attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm">
<Paperclip size={14} className="text-gray-400" />
<span className="text-gray-700 max-w-[200px] truncate">{file.name}</span>
<span className="text-gray-400 text-xs">({(file.size / 1024).toFixed(1)}KB)</span>
<button onClick={() => removeAttachment(index)} className="text-gray-400 hover:text-red-500">
<X size={14} />
</button>
</div>
))}
</div>
</div>
)}
</div>
{/* 하단 영역 */}
<div className="px-5 py-4 flex items-center justify-between border-t border-gray-100 bg-gray-50">
<div className="flex items-center gap-2">
<button
onClick={handleSend}
disabled={isSending}
className="flex items-center gap-2 px-6 py-2.5 bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send size={16} />
<span>{isSending ? '발송 중...' : '보내기'}</span>
</button>
<div className="flex items-center ml-2 border-l border-gray-200 pl-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<Tooltip title="서식" arrow placement="top" TransitionComponent={Fade}>
<IconButton size="small" onClick={() => setShowFormatBar(!showFormatBar)} sx={{ color: showFormatBar ? '#6366f1' : '#9ca3af', '&:hover': { color: '#6366f1', backgroundColor: '#eef2ff' } }}>
<Bold size={18} />
</IconButton>
</Tooltip>
<Tooltip title="링크" arrow placement="top" TransitionComponent={Fade}>
<IconButton size="small" onClick={handleInsertLink} sx={{ color: '#9ca3af', '&:hover': { color: '#6366f1', backgroundColor: '#eef2ff' } }}>
<LinkIcon size={18} />
</IconButton>
</Tooltip>
<Tooltip title="첨부파일" arrow placement="top" TransitionComponent={Fade}>
<IconButton size="small" onClick={handleFileButtonClick} sx={{ color: '#9ca3af', '&:hover': { color: '#6366f1', backgroundColor: '#eef2ff' } }}>
<Paperclip size={18} />
</IconButton>
</Tooltip>
</div>
</div>
{/* 서식 툴바 */}
{showFormatBar && (
<div className="absolute bottom-20 left-5 bg-white border border-gray-200 rounded-lg shadow-lg p-2 flex items-center gap-1">
<Tooltip title="굵게" arrow placement="top" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={() => applyFormat('bold')}
sx={{
color: activeFormats.bold ? '#6366f1' : '#6b7280',
backgroundColor: activeFormats.bold ? '#eef2ff' : 'transparent',
'&:hover': { backgroundColor: '#f3f4f6' }
}}
>
<Bold size={16} />
</IconButton>
</Tooltip>
<Tooltip title="기울임" arrow placement="top" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={() => applyFormat('italic')}
sx={{
color: activeFormats.italic ? '#6366f1' : '#6b7280',
backgroundColor: activeFormats.italic ? '#eef2ff' : 'transparent',
'&:hover': { backgroundColor: '#f3f4f6' }
}}
>
<Italic size={16} />
</IconButton>
</Tooltip>
<Tooltip title="밑줄" arrow placement="top" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={() => applyFormat('underline')}
sx={{
color: activeFormats.underline ? '#6366f1' : '#6b7280',
backgroundColor: activeFormats.underline ? '#eef2ff' : 'transparent',
'&:hover': { backgroundColor: '#f3f4f6' }
}}
>
<Underline size={16} />
</IconButton>
</Tooltip>
</div>
)}
</div>
</div>
</div>
);
};
export default ComposeModal;

View file

@ -0,0 +1,107 @@
/**
* 확인 다이얼로그 컴포넌트
* 삭제, 스팸 신고, 별표 중요한 액션 전에 확인 요청
*/
import React from 'react';
import { AlertTriangle, Trash2, AlertOctagon, Star, RotateCcw } from 'lucide-react';
const ConfirmDialog = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = '확인',
cancelText = '취소',
type = 'warning' // warning, danger, info
}) => {
if (!isOpen) return null;
//
const typeConfig = {
warning: {
icon: AlertTriangle,
bgColor: 'bg-amber-100',
iconColor: 'text-amber-600',
buttonColor: 'bg-amber-500 hover:bg-amber-600'
},
danger: {
icon: Trash2,
bgColor: 'bg-red-100',
iconColor: 'text-red-600',
buttonColor: 'bg-red-500 hover:bg-red-600'
},
spam: {
icon: AlertOctagon,
bgColor: 'bg-orange-100',
iconColor: 'text-orange-600',
buttonColor: 'bg-orange-500 hover:bg-orange-600'
},
star: {
icon: Star,
bgColor: 'bg-amber-100',
iconColor: 'text-amber-500',
buttonColor: 'bg-amber-500 hover:bg-amber-600'
},
restore: {
icon: RotateCcw,
bgColor: 'bg-emerald-100',
iconColor: 'text-emerald-600',
buttonColor: 'bg-emerald-500 hover:bg-emerald-600'
}
};
const config = typeConfig[type] || typeConfig.warning;
const Icon = config.icon;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 배경 오버레이 */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* 다이얼로그 */}
<div className="relative bg-white rounded-2xl shadow-2xl max-w-sm w-full mx-4 overflow-hidden animate-in zoom-in-95 duration-200">
<div className="p-6">
{/* 아이콘 */}
<div className={`w-12 h-12 rounded-full ${config.bgColor} flex items-center justify-center mx-auto mb-4`}>
<Icon className={`h-6 w-6 ${config.iconColor}`} />
</div>
{/* 제목 */}
<h3 className="text-lg font-semibold text-gray-900 text-center mb-2">
{title}
</h3>
{/* 메시지 */}
<p className="text-sm text-gray-500 text-center mb-6">
{message}
</p>
{/* 버튼 */}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
>
{cancelText}
</button>
<button
onClick={async () => {
await onConfirm();
onClose();
}}
className={`flex-1 px-4 py-2.5 ${config.buttonColor} text-white rounded-xl font-medium transition-colors`}
>
{confirmText}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View file

@ -0,0 +1,176 @@
import React, { useEffect, useState, useRef } from 'react';
import { useMail } from '../context/MailContext';
import Header from './Header';
import Sidebar from './Sidebar';
import MailList from './MailList';
import MailDetail from './MailDetail';
import ComposeModal from './ComposeModal';
import { useParams, useNavigate } from 'react-router-dom';
import { decodeEmailId } from '../utils/emailIdEncoder';
import toast from 'react-hot-toast';
/**
* 대시보드 메인 컴포넌트
* 사이드바, 메일 목록, 메일 상세, 메일 작성 모달 레이아웃
*/
const Dashboard = () => {
const { fetchMailboxes, fetchEmails, emails, selectEmail, setSelectedEmail, selectedEmail, selectedBox } = useMail();
const { mailbox, emailId } = useParams();
const navigate = useNavigate();
const [isComposeOpen, setIsComposeOpen] = useState(false);
const [draftData, setDraftData] = useState(null);
const [composeMode, setComposeMode] = useState('compose'); // compose, reply, forward
const [replyData, setReplyData] = useState(null);
// selectedEmail ( )
const prevSelectedEmailRef = useRef(selectedEmail);
//
useEffect(() => {
fetchMailboxes();
// ( )
const deniedMessage = sessionStorage.getItem('admin_denied_message');
if (deniedMessage) {
toast.error(deniedMessage, { duration: 4000, icon: '🚫' });
sessionStorage.removeItem('admin_denied_message');
}
// : emailId URL /
// ( )
const navType = performance.getEntriesByType('navigation')[0]?.type;
const isReloadOrDirect = navType === 'reload' || navType === 'navigate';
if (emailId && isReloadOrDirect) {
const currentMailbox = mailbox || 'inbox';
navigate(`/mail/${currentMailbox}`, { replace: true });
}
}, []);
//
useEffect(() => {
if (mailbox) {
fetchEmails(mailbox);
}
}, [mailbox]);
// URL emailId ( )
useEffect(() => {
if (emailId) {
// ID
const decodedId = decodeEmailId(emailId);
if (decodedId) {
const found = emails.find(e => e.id === decodedId);
if (found && (!selectedEmail || selectedEmail.id !== found.id)) {
selectEmail(found);
}
}
} else {
setSelectedEmail(null);
}
}, [emailId, emails, selectedEmail]);
// selectedEmail null URL emailId
useEffect(() => {
const prevEmail = prevSelectedEmailRef.current;
// null URL
if (prevEmail && !selectedEmail && emailId) {
const currentMailbox = mailbox || selectedBox?.toLowerCase() || 'inbox';
navigate(`/mail/${currentMailbox}`, { replace: true });
}
prevSelectedEmailRef.current = selectedEmail;
}, [selectedEmail, emailId, mailbox, selectedBox, navigate]);
//
const handleComposeClick = () => {
setDraftData(null);
setComposeMode('compose');
setReplyData(null);
setIsComposeOpen(true);
};
// -
const handleCloseCompose = () => {
setIsComposeOpen(false);
const hadDraftData = draftData !== null;
setDraftData(null);
setComposeMode('compose');
setReplyData(null);
// ( )
if (hadDraftData || mailbox?.toUpperCase() === 'DRAFTS') {
fetchEmails('DRAFTS');
}
};
//
const handleContinueDraft = (email) => {
setDraftData(email);
setComposeMode('compose');
setReplyData(null);
setIsComposeOpen(true);
};
//
const handleReply = (email) => {
setDraftData(null);
setComposeMode('reply');
setReplyData(email);
setIsComposeOpen(true);
};
//
const handleForward = (email) => {
setDraftData(null);
setComposeMode('forward');
setReplyData(email);
setIsComposeOpen(true);
};
const activeBox = mailbox ? mailbox.toUpperCase() : 'INBOX';
return (
<div className="flex h-screen bg-white overflow-hidden font-sans text-slate-800 min-w-[1400px] overflow-x-auto">
{/* 사이드바 */}
<div className="w-80 flex-shrink-0 flex flex-col bg-white">
<Sidebar onComposeClick={handleComposeClick} activeBox={activeBox} />
</div>
{/* 메인 콘텐츠 */}
<div className="flex-1 flex flex-col min-w-0 bg-white">
<Header />
<div className="flex-1 flex overflow-hidden">
{/* 메일 목록 */}
<div className="w-96 flex-shrink-0 flex flex-col bg-white">
<MailList />
</div>
{/* 구분선 */}
<div className="w-px bg-gray-200 flex-shrink-0 mt-14"></div>
{/* 메일 상세 */}
<div className="flex-1 flex flex-col min-w-0 bg-white relative">
<MailDetail
onContinueDraft={handleContinueDraft}
onReply={handleReply}
onForward={handleForward}
/>
</div>
</div>
</div>
{/* 메일 작성 모달 */}
<ComposeModal
isOpen={isComposeOpen}
onClose={handleCloseCompose}
draftData={draftData}
mode={composeMode}
replyData={replyData}
/>
</div>
);
};
export default Dashboard;

View file

@ -0,0 +1,671 @@
/**
* 헤더 컴포넌트
* 검색바, 검색 필터, 프로필 메뉴, 관리자 버튼
*/
import React, { useState, useRef, useEffect } from 'react';
import { useMail } from '../context/MailContext';
import { Search, SlidersHorizontal, ChevronDown, Check, Calendar as CalendarIcon, Settings, LogOut, User, Shield, X, Clock, Trash2 } from 'lucide-react';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
import Checkbox from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
import DatePicker, { registerLocale } from 'react-datepicker';
import "react-datepicker/dist/react-datepicker.css";
import "./datepicker-custom.css";
import ko from 'date-fns/locale/ko';
import { Menu, Avatar } from '@mui/material';
import { useNavigate } from 'react-router-dom';
registerLocale('ko', ko);
//
const sizeOperatorOptions = [
{ label: '초과', value: 'greater' },
{ label: '미만', value: 'less' }
];
const sizeUnitOptions = [
{ label: 'MB', value: 'MB' },
{ label: 'KB', value: 'KB' },
{ label: 'Bytes', value: 'B' }
];
const dateWithinOptions = [
{ label: '전체 기간', value: '' },
{ label: '1일', value: '1d' },
{ label: '1주', value: '1w' },
{ label: '1개월', value: '1m' },
{ label: '6개월', value: '6m' },
{ label: '1년', value: '1y' },
{ label: '직접 입력', value: 'custom' },
];
const scopeOptions = [
{ label: '전체보관함', value: 'all' },
{ label: '받은편지함', value: 'inbox' },
{ label: '보낸편지함', value: 'sent' },
{ label: '중요편지함', value: 'important' },
{ label: '임시보관함', value: 'drafts' },
{ label: '스팸함', value: 'spam' },
{ label: '휴지통', value: 'trash' },
];
const getLabel = (options, value) => options.find(o => o.value === value)?.label || '';
const CustomDateInput = React.forwardRef(({ value, onClick, onChange, placeholder }, ref) => (
<div className="relative w-full cursor-pointer">
<input
ref={ref}
readOnly
value={value}
onClick={onClick}
onChange={onChange}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none transition-all cursor-pointer"
placeholder={placeholder}
/>
<CalendarIcon className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
</div>
));
const AnimatedDropdown = ({ isOpen, options, value, onChange, onClose }) => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (isOpen) setShouldRender(true);
else {
const timer = setTimeout(() => setShouldRender(false), 200);
return () => clearTimeout(timer);
}
}, [isOpen]);
if (!shouldRender) return null;
return (
<div className={`absolute top-full left-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-[60] py-1 min-w-[max-content] ${isOpen ? 'animate-fade-in-down' : 'animate-fade-out-up'}`}>
{options.map((opt) => (
<button
key={opt.value}
onClick={(e) => { e.stopPropagation(); onChange(opt.value); onClose(); }}
type="button"
className={`w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center justify-between gap-3 whitespace-nowrap ${value === opt.value ? 'bg-blue-50 text-blue-600 font-medium' : 'text-gray-700'}`}
>
<span>{opt.label}</span>
{value === opt.value && <Check className="h-4 w-4" />}
</button>
))}
</div>
);
};
const Header = () => {
const navigate = useNavigate();
const { user, logout, searchEmails, clearSearch, isSearchMode, searchScope: currentScope, searchHistory, removeSearchHistory, clearSearchHistory } = useMail();
const [showFilter, setShowFilter] = useState(false);
const [isFilterVisible, setIsFilterVisible] = useState(false);
const filterRef = useRef(null);
const [anchorEl, setAnchorEl] = useState(null);
const openMenu = Boolean(anchorEl);
//
const [simpleSearchQuery, setSimpleSearchQuery] = useState('');
const [searchParams, setSearchParams] = useState({
from: '', to: '', subject: '', includes: '', excludes: '',
sizeOperator: 'greater', sizeValue: '', sizeUnit: 'MB',
dateWithin: '', dateReference: '', scope: 'all',
hasAttachment: false
});
const [activeDropdown, setActiveDropdown] = useState(null);
//
const [showHistory, setShowHistory] = useState(false);
const [historyIndex, setHistoryIndex] = useState(-1);
const searchInputRef = useRef(null);
// (Enter )
const handleSimpleSearch = (query = simpleSearchQuery) => {
if (query.trim()) {
searchEmails(query.trim(), searchParams.scope.toUpperCase());
setShowFilter(false);
setShowHistory(false);
setHistoryIndex(-1);
}
};
//
const handleAdvancedSearch = () => {
const query = simpleSearchQuery.trim();
//
const filters = {};
if (searchParams.from) filters.from = searchParams.from;
if (searchParams.to) filters.to = searchParams.to;
if (searchParams.subject) filters.subject = searchParams.subject;
if (searchParams.includes) filters.includes = searchParams.includes;
if (searchParams.excludes) filters.excludes = searchParams.excludes;
if (searchParams.hasAttachment) filters.hasAttachment = true;
// (bytes )
if (searchParams.sizeValue) {
const sizeBytes = parseFloat(searchParams.sizeValue) *
(searchParams.sizeUnit === 'MB' ? 1024 * 1024 :
searchParams.sizeUnit === 'KB' ? 1024 : 1);
if (searchParams.sizeOperator === 'greater') {
filters.minSize = Math.floor(sizeBytes);
} else {
filters.maxSize = Math.floor(sizeBytes);
}
}
//
if (searchParams.dateWithin && searchParams.dateWithin !== 'custom') {
const now = new Date();
let dateAfter = new Date();
switch (searchParams.dateWithin) {
case '1d': dateAfter.setDate(now.getDate() - 1); break;
case '1w': dateAfter.setDate(now.getDate() - 7); break;
case '1m': dateAfter.setMonth(now.getMonth() - 1); break;
case '6m': dateAfter.setMonth(now.getMonth() - 6); break;
case '1y': dateAfter.setFullYear(now.getFullYear() - 1); break;
default: dateAfter = null;
}
if (dateAfter) {
filters.dateAfter = dateAfter.toISOString().split('T')[0];
}
} else if (searchParams.dateReference) {
//
filters.dateBefore = searchParams.dateReference;
}
//
const hasFilters = Object.keys(filters).length > 0;
if (query || hasFilters) {
searchEmails(query, searchParams.scope.toUpperCase(), filters);
setShowFilter(false);
}
};
// Enter ( )
const handleSearchKeyDown = (e) => {
//
if (showHistory && searchHistory.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setHistoryIndex(prev =>
prev < searchHistory.length - 1 ? prev + 1 : 0
);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setHistoryIndex(prev =>
prev > 0 ? prev - 1 : searchHistory.length - 1
);
return;
}
if (e.key === 'Enter' && historyIndex >= 0) {
e.preventDefault();
const selectedQuery = searchHistory[historyIndex];
setSimpleSearchQuery(selectedQuery);
handleSimpleSearch(selectedQuery);
return;
}
}
if (e.key === 'Enter') {
handleSimpleSearch();
} else if (e.key === 'Escape') {
setSimpleSearchQuery('');
setShowHistory(false);
clearSearch();
}
};
//
const handleHistoryClick = (query, e) => {
e.preventDefault();
e.stopPropagation();
setSimpleSearchQuery(query);
handleSimpleSearch(query);
};
//
const handleHistoryDelete = (index, e) => {
e.preventDefault();
e.stopPropagation();
removeSearchHistory(index);
};
//
useEffect(() => {
if (!isSearchMode) {
setSimpleSearchQuery('');
setHistoryIndex(-1);
}
}, [isSearchMode]);
useEffect(() => {
const handleClickOutside = (event) => {
if (filterRef.current && !filterRef.current.contains(event.target)) {
setShowFilter(false);
setActiveDropdown(null);
setShowHistory(false);
setHistoryIndex(-1);
}
};
if (showFilter || showHistory) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilter, showHistory]);
const [calendarOpen, setCalendarOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const closeCalendar = () => {
setIsClosing(true);
setTimeout(() => { setCalendarOpen(false); setIsClosing(false); }, 200);
};
const toggleCalendar = () => {
if (calendarOpen || isClosing) {
if (!isClosing) closeCalendar();
} else {
setCalendarOpen(true);
}
};
useEffect(() => {
if (showFilter) setIsFilterVisible(true);
else {
const timer = setTimeout(() => setIsFilterVisible(false), 200);
return () => clearTimeout(timer);
}
}, [showFilter]);
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setSearchParams(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value }));
};
const handleReset = () => {
setSearchParams({
from: '', to: '', subject: '', includes: '', excludes: '',
sizeOperator: 'greater', sizeValue: '', sizeUnit: 'MB',
dateWithin: '', dateReference: '', scope: 'all',
hasAttachment: false
});
setActiveDropdown(null);
setSimpleSearchQuery('');
clearSearch();
};
//
const handleDateWithinChange = (value) => {
setSearchParams(prev => ({
...prev,
dateWithin: value,
// dateReference
dateReference: value === 'custom' ? prev.dateReference : ''
}));
};
// " "
const handleDateReferenceChange = (date) => {
const formatted = date ? new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().split('T')[0] : '';
setSearchParams(prev => ({
...prev,
dateReference: formatted,
dateWithin: formatted ? 'custom' : prev.dateWithin
}));
closeCalendar();
};
const handleProfileClick = (event) => setAnchorEl(event.currentTarget);
const handleProfileClose = () => setAnchorEl(null);
const handleLogout = () => { handleProfileClose(); logout(); };
const inputStyle = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none transition-all";
const labelStyle = "text-sm font-medium text-gray-600 mb-1.5";
return (
<div className="py-4 px-6 bg-white border-b border-gray-100 flex items-center justify-between flex-shrink-0 relative z-30">
{/* 검색 바 */}
<div className="flex-1 max-w-2xl">
<div className="relative" ref={filterRef}>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
ref={searchInputRef}
type="text"
value={simpleSearchQuery}
onChange={(e) => setSimpleSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
onFocus={() => setShowHistory(true)}
className={`block w-full pl-12 pr-12 h-11 border rounded-xl leading-5 placeholder-gray-400 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-300 text-sm transition-all ${
isSearchMode
? 'border-blue-400 bg-blue-50'
: 'border-gray-200 bg-gray-50 focus:bg-white'
}`}
placeholder="메일 검색... (Enter로 검색)"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<Tooltip title={showFilter ? "" : "검색 옵션"} arrow placement="bottom" TransitionComponent={Fade}>
<button
className={`p-2 rounded-lg transition-all ${showFilter ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600 hover:bg-gray-100'}`}
onClick={() => setShowFilter(!showFilter)}
>
<SlidersHorizontal className="h-5 w-5" />
</button>
</Tooltip>
</div>
{/* 검색 히스토리 드롭다운 */}
{showHistory && searchHistory.length > 0 && !showFilter && (
<div className="absolute top-full mt-2 left-0 right-0 bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden z-50 animate-fade-in-down">
<div className="py-2">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<span className="text-xs font-medium text-gray-500 flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
최근 검색
</span>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearSearchHistory();
}}
className="text-xs text-gray-400 hover:text-red-500 transition-colors"
>
전체 삭제
</button>
</div>
{searchHistory.map((item, index) => (
<div
key={index}
onClick={(e) => handleHistoryClick(item, e)}
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-all group ${
historyIndex === index
? 'bg-blue-50 text-blue-600'
: 'hover:bg-gray-50 text-gray-700'
}`}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Search className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="truncate text-sm">{item}</span>
</div>
<button
onClick={(e) => handleHistoryDelete(index, e)}
className="p-1 rounded-md text-gray-400 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-all"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* 검색 필터 드롭다운 */}
{isFilterVisible && (
<div
className={`absolute top-full mt-2 right-0 w-[580px] bg-white rounded-2xl shadow-2xl border border-gray-100 p-6 z-50 origin-top-right select-none ${showFilter ? 'animate-fade-in-down' : 'animate-fade-out-up'}`}
onClick={() => activeDropdown && setActiveDropdown(null)}
>
<h3 className="text-base font-bold text-gray-800 mb-5">상세 검색</h3>
{/* 기본 필드 - 2열 그리드 */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className={labelStyle}>보낸사람</label>
<input name="from" value={searchParams.from} onChange={handleInputChange} className={inputStyle} placeholder="이메일 주소" />
</div>
<div>
<label className={labelStyle}>받는사람</label>
<input name="to" value={searchParams.to} onChange={handleInputChange} className={inputStyle} placeholder="이메일 주소" />
</div>
</div>
<div className="mb-4">
<label className={labelStyle}>제목</label>
<input name="subject" value={searchParams.subject} onChange={handleInputChange} className={inputStyle} placeholder="제목 검색" />
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className={labelStyle}>포함하는 단어</label>
<input name="includes" value={searchParams.includes} onChange={handleInputChange} className={inputStyle} placeholder="포함할 키워드" />
</div>
<div>
<label className={labelStyle}>제외할 단어</label>
<input name="excludes" value={searchParams.excludes} onChange={handleInputChange} className={inputStyle} placeholder="제외할 키워드" />
</div>
</div>
{/* 크기 필터 */}
<div className="mb-4">
<label className={labelStyle}>크기</label>
<div className="flex items-center gap-2">
<div className="relative w-24">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setActiveDropdown(activeDropdown === 'sizeOperator' ? null : 'sizeOperator'); }}
className="w-full text-left border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white flex items-center justify-between focus:border-blue-400 transition-all"
>
<span>{getLabel(sizeOperatorOptions, searchParams.sizeOperator)}</span>
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${activeDropdown === 'sizeOperator' ? 'rotate-180' : ''}`} />
</button>
<AnimatedDropdown
isOpen={activeDropdown === 'sizeOperator'}
options={sizeOperatorOptions}
value={searchParams.sizeOperator}
onChange={(val) => setSearchParams(prev => ({ ...prev, sizeOperator: val }))}
onClose={() => setActiveDropdown(null)}
/>
</div>
<input
name="sizeValue" type="number" value={searchParams.sizeValue} onChange={handleInputChange}
className="flex-1 border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 text-center bg-white focus:border-blue-400 focus:ring-2 focus:ring-blue-100 outline-none transition-all"
placeholder="크기"
/>
<div className="relative w-20">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setActiveDropdown(activeDropdown === 'sizeUnit' ? null : 'sizeUnit'); }}
className="w-full text-left border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white flex items-center justify-between focus:border-blue-400 transition-all"
>
<span>{getLabel(sizeUnitOptions, searchParams.sizeUnit)}</span>
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${activeDropdown === 'sizeUnit' ? 'rotate-180' : ''}`} />
</button>
<AnimatedDropdown
isOpen={activeDropdown === 'sizeUnit'}
options={sizeUnitOptions}
value={searchParams.sizeUnit}
onChange={(val) => setSearchParams(prev => ({ ...prev, sizeUnit: val }))}
onClose={() => setActiveDropdown(null)}
/>
</div>
</div>
</div>
{/* 기간 필터 - Gmail 스타일 */}
<div className="mb-4">
<label className={labelStyle}>기간</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setActiveDropdown(activeDropdown === 'dateWithin' ? null : 'dateWithin'); }}
className="w-full text-left border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white flex items-center justify-between focus:border-blue-400 transition-all"
>
<span>{getLabel(dateWithinOptions, searchParams.dateWithin) || '전체 기간'}</span>
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${activeDropdown === 'dateWithin' ? 'rotate-180' : ''}`} />
</button>
<AnimatedDropdown
isOpen={activeDropdown === 'dateWithin'}
options={dateWithinOptions}
value={searchParams.dateWithin}
onChange={handleDateWithinChange}
onClose={() => setActiveDropdown(null)}
/>
</div>
<div className="flex-1 custom-datepicker-wrapper">
<DatePicker
selected={searchParams.dateReference ? new Date(searchParams.dateReference) : null}
onChange={handleDateReferenceChange}
open={calendarOpen || isClosing}
onClickOutside={closeCalendar}
onInputClick={toggleCalendar}
calendarClassName={isClosing ? "react-datepicker-closing" : ""}
dateFormat="yyyy/MM/dd"
locale="ko"
placeholderText="날짜 선택"
maxDate={new Date()}
popperClassName="z-[60]"
customInput={<CustomDateInput />}
/>
</div>
</div>
</div>
{/* 검색 범위 */}
<div className="mb-4">
<label className={labelStyle}>검색 범위</label>
<div className="relative">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setActiveDropdown(activeDropdown === 'scope' ? null : 'scope'); }}
className="w-full text-left border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white flex items-center justify-between focus:border-blue-400 transition-all"
>
<span>{getLabel(scopeOptions, searchParams.scope)}</span>
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${activeDropdown === 'scope' ? 'rotate-180' : ''}`} />
</button>
<AnimatedDropdown
isOpen={activeDropdown === 'scope'}
options={scopeOptions}
value={searchParams.scope}
onChange={(val) => setSearchParams(prev => ({ ...prev, scope: val }))}
onClose={() => setActiveDropdown(null)}
/>
</div>
</div>
{/* 체크박스 옵션 */}
<div className="flex items-center mb-4">
<FormControlLabel
control={<Checkbox checked={searchParams.hasAttachment} onChange={handleInputChange} name="hasAttachment" size="small" sx={{ color: '#9ca3af', '&.Mui-checked': { color: '#3b82f6' } }} />}
label={<span className="text-sm text-gray-600">첨부파일 있음</span>}
/>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-100">
<button
onClick={handleReset}
className="px-4 py-2.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl font-medium text-sm transition-all"
>
초기화
</button>
<button
onClick={handleAdvancedSearch}
className="px-6 py-2.5 bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 transition-all text-sm"
>
적용
</button>
</div>
</div>
)}
</div>
</div>
{/* 우측 아이콘 */}
<div className="ml-6 flex items-center space-x-3">
{/* 관리자 설정 버튼 */}
{user && user.isAdmin && (
<Tooltip title="관리자 설정">
<button
onClick={() => navigate('/admin/dashboard')}
className="p-2.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-xl transition-all"
>
<Settings className="h-5 w-5" />
</button>
</Tooltip>
)}
{/* 프로필 버튼 */}
<button
onClick={handleProfileClick}
className="flex items-center justify-center rounded-full hover:ring-4 hover:ring-blue-50 transition-all"
>
<Avatar sx={{ bgcolor: '#e0e7ff', color: '#6366f1', width: 40, height: 40, fontSize: '1rem', fontWeight: 600 }}>
{user?.name?.charAt(0)?.toUpperCase() || <User size={20} />}
</Avatar>
</button>
{/* 프로필 메뉴 - 개선된 디자인 */}
<Menu
anchorEl={anchorEl}
open={openMenu}
onClose={handleProfileClose}
PaperProps={{
elevation: 0,
sx: {
overflow: 'visible',
filter: 'drop-shadow(0px 4px 20px rgba(0,0,0,0.12))',
mt: 1.5,
minWidth: 280,
borderRadius: '16px',
border: '1px solid #f3f4f6'
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<div className="p-4">
{/* 사용자 정보 카드 */}
<div className="flex items-center gap-4 p-4 bg-gradient-to-br from-indigo-50 to-blue-50 rounded-xl mb-3">
<Avatar sx={{
bgcolor: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
width: 52,
height: 52,
fontSize: '1.25rem',
fontWeight: 700,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)'
}}>
{user?.name?.charAt(0)?.toUpperCase() || 'U'}
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-base font-bold text-gray-800 truncate">{user?.name || '사용자'}</p>
{user?.isAdmin && (
<span className="flex items-center gap-1 px-2 py-0.5 bg-indigo-100 text-indigo-600 text-xs font-semibold rounded-full">
<Shield size={10} />
관리자
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">{user?.email}</p>
</div>
</div>
{/* 로그아웃 버튼 */}
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 text-gray-600 hover:text-red-500 hover:bg-red-50 rounded-xl transition-all"
>
<LogOut size={18} />
<span className="text-sm font-medium">로그아웃</span>
</button>
</div>
</Menu>
</div>
</div>
);
};
export default Header;

View file

@ -0,0 +1,173 @@
/**
* 로그인 페이지 컴포넌트
* 현대적인 UI 디자인 적용 (그라디언트 배경, 글래스모피즘 카드)
*/
import React, { useState, useEffect } from 'react';
import { useMail } from '../context/MailContext';
import { Lock, User, Mail } from 'lucide-react';
import { useNavigate, useLocation } from 'react-router-dom';
import Button from '@mui/material/Button';
const Login = () => {
const { login, error, user } = useMail();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const from = location.state?.from?.pathname || '/';
useEffect(() => {
if (user) {
// [/UX] Admin ,
if (from.startsWith('/admin')) {
navigate(from, { replace: true });
} else {
navigate('/', { replace: true });
}
}
}, [user, navigate, from]);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
await login(email, password);
setIsLoading(false);
};
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden">
{/* 그라디언트 배경 */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50"></div>
{/* 배경 장식 요소 */}
<div className="absolute top-0 left-0 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute bottom-0 right-0 w-96 h-96 bg-indigo-400/20 rounded-full blur-3xl translate-x-1/2 translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-64 h-64 bg-purple-400/10 rounded-full blur-2xl -translate-x-1/2 -translate-y-1/2"></div>
{/* 메인 컨텐츠 */}
<div className="relative z-10 w-full max-w-md px-4">
{/* 로고 및 타이틀 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl shadow-lg shadow-blue-500/30 mb-6 animate-pulse">
<Mail className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-800 mb-2">
Mailbox
</h1>
<p className="text-gray-500">
안전한 이메일 서비스
</p>
</div>
{/* 로그인 카드 */}
<div className="bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl shadow-gray-200/50 border border-white/50 p-8">
<form className="space-y-5" onSubmit={handleSubmit}>
{/* 아이디 입력 */}
<div>
<label htmlFor="email" className="block text-sm font-semibold text-gray-700 mb-2">
아이디
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
name="email"
type="text"
autoComplete="username"
required
placeholder="아이디를 입력하세요"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-12 pr-4 py-3.5 bg-gray-50/50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400 transition-all duration-200"
/>
</div>
</div>
{/* 비밀번호 입력 */}
<div>
<label htmlFor="password" className="block text-sm font-semibold text-gray-700 mb-2">
비밀번호
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
placeholder="비밀번호를 입력하세요"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-12 pr-4 py-3.5 bg-gray-50/50 border border-gray-200 rounded-xl text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-400 transition-all duration-200"
/>
</div>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-red-50 border border-red-100 rounded-xl p-4">
<p className="text-sm text-red-600 font-medium text-center">{error}</p>
</div>
)}
{/* 로그인 버튼 */}
<Button
type="submit"
fullWidth
disabled={isLoading}
variant="contained"
disableElevation
sx={{
py: 1.75,
fontSize: '0.95rem',
fontWeight: 600,
borderRadius: '0.85rem',
textTransform: 'none',
background: 'linear-gradient(135deg, #4F8EF7 0%, #6366F1 100%)',
boxShadow: '0 8px 24px rgba(99, 102, 241, 0.25)',
'&:hover': {
background: 'linear-gradient(135deg, #3B7EE5 0%, #5355E8 100%)',
boxShadow: '0 12px 28px rgba(99, 102, 241, 0.35)',
},
'&:disabled': {
background: '#e5e7eb',
}
}}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
로그인 ...
</span>
) : '로그인'}
</Button>
</form>
{/* 하단 안내 */}
<div className="mt-6 pt-6 border-t border-gray-100">
<p className="text-center text-sm text-gray-400">
로그인 문제 발생 관리자에게 문의하세요
</p>
</div>
</div>
{/* 푸터 */}
<div className="mt-8 text-center text-xs text-gray-400">
© 2024 Mailbox Service. All rights reserved.
</div>
</div>
</div>
);
};
export default Login;

View file

@ -0,0 +1,738 @@
/**
* 메일 상세 보기 컴포넌트
* 선택된 메일의 내용, 첨부파일, 액션 버튼 표시
*/
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMail } from '../context/MailContext';
import { Trash2, Printer, Star, Reply, Forward, Mail as MailIcon, MailOpen, Archive, Paperclip, Download, FileText, Image, File, Edit, AlertOctagon, Languages, Sparkles } from 'lucide-react';
import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
import toast from 'react-hot-toast';
import { decodeHtmlEntities } from '../utils/decodeHtmlEntities';
import { encodeEmailId } from '../utils/emailIdEncoder';
import { HighlightText } from '../utils/highlightText';
import ConfirmDialog from './ConfirmDialog';
const MailDetail = ({ onContinueDraft, onReply, onForward }) => {
const navigate = useNavigate();
const { selectedEmail, setSelectedEmail, toggleStar, moveToTrash, markAsUnread, markAsRead, restoreEmail, deleteEmail, selectedBox, moveToSpam, moveEmail, userNames, isSearchMode, searchQuery } = useMail();
//
const [confirmDialog, setConfirmDialog] = useState({ isOpen: false, type: '', title: '', message: '', onConfirm: () => {} });
//
const [isTranslating, setIsTranslating] = useState(false);
const [translatedContent, setTranslatedContent] = useState(null);
const [showTranslation, setShowTranslation] = useState(false);
const [targetLang, setTargetLang] = useState('ko');
const [usedModel, setUsedModel] = useState('');
//
useEffect(() => {
setTranslatedContent(null);
setShowTranslation(false);
setIsTranslating(false);
setUsedModel('');
}, [selectedEmail?.id]);
// ( ) -
const handleDelete = () => {
setConfirmDialog({
isOpen: true,
type: 'danger',
title: '메일 삭제',
message: '이 메일을 휴지통으로 이동하시겠습니까?',
confirmText: '삭제',
onConfirm: async () => {
if (selectedEmail) {
const result = await moveToTrash(selectedEmail.id, selectedBox);
if (result?.trashId) {
toast((t) => (
<div className="flex items-center gap-3">
<span>휴지통으로 이동했습니다.</span>
<button
onClick={async () => {
toast.dismiss(t.id);
await restoreEmail(result.trashId, 'TRASH');
toast.success('복구되었습니다.');
}}
className="text-blue-500 font-medium hover:text-blue-600"
>
실행취소
</button>
</div>
), { duration: 5000 });
} else {
toast.success('휴지통으로 이동했습니다.');
}
}
}
});
};
// () -
const handlePermanentDelete = () => {
setConfirmDialog({
isOpen: true,
type: 'danger',
title: '영구 삭제',
message: '이 메일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
confirmText: '영구 삭제',
onConfirm: () => {
if (selectedEmail) {
deleteEmail(selectedEmail.id, selectedBox);
toast.success('메일이 영구 삭제되었습니다.');
}
}
});
};
// /
const handleToggleRead = () => {
if (selectedEmail) {
if (selectedEmail.isRead) {
markAsUnread(selectedEmail.id, selectedBox);
toast.success('읽지 않음으로 표시했습니다.');
} else {
markAsRead(selectedEmail.id, selectedBox);
toast.success('읽음으로 표시했습니다.');
}
}
};
// () -
const handleRestore = async () => {
if (selectedEmail) {
await restoreEmail(selectedEmail.id, selectedBox);
setSelectedEmail(null); //
toast.success('메일이 복구되었습니다.');
}
};
// () -
const handleToggleStar = (e) => {
e.stopPropagation();
if (!selectedEmail) return;
const isCurrentlyStarred = selectedBox === 'IMPORTANT';
setConfirmDialog({
isOpen: true,
type: 'star',
title: isCurrentlyStarred ? '중요 표시 해제' : '중요 표시',
message: isCurrentlyStarred
? '중요 표시를 해제하고 받은편지함으로 이동하시겠습니까?'
: '이 메일을 중요편지함으로 이동하시겠습니까?',
confirmText: isCurrentlyStarred ? '해제' : '이동',
onConfirm: async () => {
const result = await toggleStar(selectedEmail.id);
if (result) {
if (result.movedTo === 'important') {
toast.success('중요편지함으로 이동했습니다.');
} else {
toast.success('중요 표시를 해제했습니다.');
}
navigate(`/mail/${result.movedTo}/${encodeEmailId(result.newEmailId)}`);
}
}
});
};
// -
const handlePrint = () => {
if (!selectedEmail) return;
const printWindow = window.open('', '_blank');
if (!printWindow) {
toast.error('팝업이 차단되었습니다. 팝업을 허용해주세요.');
return;
}
const emailContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${selectedEmail.subject || '(제목 없음)'}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 40px; max-width: 800px; margin: 0 auto; }
h1 { font-size: 24px; margin-bottom: 20px; color: #1a1a1a; }
.meta { color: #666; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee; }
.meta p { margin: 5px 0; }
.content { line-height: 1.6; color: #333; }
@media print { body { padding: 20px; } }
</style>
</head>
<body>
<h1>${selectedEmail.subject || '(제목 없음)'}</h1>
<div class="meta">
<p><strong>보낸 사람:</strong> ${selectedEmail.from}</p>
<p><strong>받는 사람:</strong> ${selectedEmail.to}</p>
<p><strong>날짜:</strong> ${new Date(selectedEmail.date).toLocaleString('ko-KR')}</p>
</div>
<div class="content">
${selectedEmail.html || selectedEmail.text?.replace(/\n/g, '<br>') || '내용 없음'}
</div>
</body>
</html>
`;
printWindow.document.write(emailContent);
printWindow.document.close();
printWindow.focus();
//
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 250);
};
//
const handleTranslate = async (lang = 'ko') => {
if (isTranslating) return;
const content = selectedEmail.html || selectedEmail.text;
if (!content) {
toast.error('번역할 내용이 없습니다.');
return;
}
setIsTranslating(true);
setTargetLang(lang);
//
try {
const token = localStorage.getItem('email_token');
const modelRes = await fetch('/api/emails/gemini-model', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (modelRes.ok) {
const modelData = await modelRes.json();
setUsedModel(modelData.modelName || 'Gemini AI');
} else {
setUsedModel('Gemini AI');
}
} catch (e) {
setUsedModel('Gemini AI');
}
try {
const token = localStorage.getItem('email_token');
const res = await fetch('/api/emails/translate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
emailId: selectedEmail.id,
mailbox: selectedBox || 'inbox',
text: content,
targetLang: lang
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || '번역 실패');
}
const data = await res.json();
setTranslatedContent(data.translatedText);
setShowTranslation(true);
setUsedModel(data.modelName || 'Gemini AI');
if (data.cached) {
toast.success('저장된 번역을 불러왔습니다.');
} else {
toast.success('번역이 완료되었습니다.');
}
} catch (error) {
toast.error(error.message || '번역에 실패했습니다.');
setTranslatedContent(null);
setShowTranslation(false);
} finally {
setIsTranslating(false);
}
};
// (/ )
const toggleTranslation = () => {
if (translatedContent) {
setShowTranslation(!showTranslation);
}
};
if (!selectedEmail) {
return (
<div className="flex flex-col h-full bg-white">
{/* 상단 툴바 (빈 상태) */}
<div className="flex-none h-14 px-8 bg-white border-b border-gray-100 flex items-center justify-between">
<div className="flex-1"></div>
</div>
{/* 빈 상태 메시지 */}
<div className="flex-1 flex flex-col items-center justify-center bg-gray-50 text-gray-400">
<MailIcon size={64} className="mb-6 opacity-40" />
<p className="text-xl font-semibold text-gray-600">메일을 선택하세요</p>
<p className="text-sm mt-2 text-gray-500">목록에서 메일을 클릭하면 여기에 표시됩니다</p>
</div>
</div>
);
}
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('ko-KR', {
year: 'numeric', month: 'long', day: 'numeric',
hour: 'numeric', minute: 'numeric', hour12: true
});
};
const getSenderInfo = (fromStr) => {
if (!fromStr) return { name: '알 수 없음', email: '' };
// "" <email@domain.com> <email@domain.com>
const match = fromStr.match(/^(.*?)\s*<(.*?)>$/);
let headerName = '';
let parsedEmail = '';
if (match) {
headerName = match[1].replace(/['"]/g, '').trim();
parsedEmail = match[2];
} else if (fromStr.includes('@')) {
// (email@domain.com)
parsedEmail = fromStr;
} else {
return { name: decodeHtmlEntities(fromStr), email: '' };
}
// 1: DB
if (userNames && userNames[parsedEmail]) {
return { name: userNames[parsedEmail], email: parsedEmail };
}
// 2: From
if (headerName) {
return { name: decodeHtmlEntities(headerName), email: parsedEmail };
}
// 3:
return { name: parsedEmail, email: parsedEmail };
};
const { name, email } = getSenderInfo(selectedEmail.from);
//
const getFileIcon = (filename) => {
const ext = filename.split('.').pop()?.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <Image size={16} className="text-pink-500" />;
}
if (['pdf', 'doc', 'docx', 'txt', 'rtf'].includes(ext)) {
return <FileText size={16} className="text-blue-500" />;
}
return <File size={16} className="text-gray-500" />;
};
// URL
const linkifyText = (text) => {
const urlPattern = /(https?:\/\/[^\s]+)/g;
const parts = text.split(urlPattern);
return parts.map((part, index) => {
// URL ( regex )
if (/^https?:\/\//.test(part)) {
return (
<a
key={index}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
onClick={(e) => e.stopPropagation()}
>
{part}
</a>
);
}
return part;
});
};
const handleDownload = async (filename) => {
try {
const token = localStorage.getItem('email_token');
const mailbox = selectedEmail.mailbox || 'INBOX';
const url = `/api/emails/${selectedEmail.id}/attachments/${encodeURIComponent(filename)}?mailbox=${mailbox}`;
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('다운로드 실패');
const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error('첨부파일 다운로드 오류:', error);
toast.error('파일 다운로드에 실패했습니다.');
}
};
// ( )
const isStarred = selectedBox === 'IMPORTANT';
return (
<>
<div className="flex flex-col h-full bg-white">
{/* 상단 툴바 */}
<div className="flex-none h-14 px-8 bg-white border-b border-gray-100 flex items-center justify-between print:hidden">
<div className="flex-1"></div>
<div className="flex items-center gap-1 bg-gray-100 rounded-xl p-1">
{/* 휴지통 전용 툴바 */}
{selectedBox === 'TRASH' ? (
<>
<Tooltip title="영구 삭제" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handlePermanentDelete} className="p-2.5 text-gray-500 hover:text-red-500 hover:bg-white rounded-lg transition-all">
<Trash2 size={18} />
</button>
</Tooltip>
<Tooltip title={selectedEmail.isRead ? "읽지 않음으로 표시" : "읽음으로 표시"} arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleToggleRead} className="p-2.5 text-gray-500 hover:text-blue-500 hover:bg-white rounded-lg transition-all">
{selectedEmail.isRead ? <MailIcon size={18} /> : <MailOpen size={18} />}
</button>
</Tooltip>
<Tooltip title="복구" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleRestore} className="p-2.5 text-gray-500 hover:text-emerald-500 hover:bg-white rounded-lg transition-all">
<Archive size={18} />
</button>
</Tooltip>
</>
) : (
<>
{/* 일반 툴바 */}
<Tooltip title="삭제" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleDelete} className="p-2.5 text-gray-500 hover:text-red-500 hover:bg-white rounded-lg transition-all">
<Trash2 size={18} />
</button>
</Tooltip>
{/* 임시보관함이 아닐 때만 추가 버튼 표시 */}
{selectedEmail.mailbox !== 'DRAFTS' && (
<>
<Tooltip title={selectedEmail.isRead ? "읽지 않음으로 표시" : "읽음으로 표시"} arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleToggleRead} className="p-2.5 text-gray-500 hover:text-blue-500 hover:bg-white rounded-lg transition-all">
{selectedEmail.isRead ? <MailIcon size={18} /> : <MailOpen size={18} />}
</button>
</Tooltip>
{/* 스팸함 이동 버튼 (스팸함/중요편지함 제외) */}
{selectedBox !== 'SPAM' && selectedBox !== 'IMPORTANT' && (
<Tooltip title="스팸 신고" arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => {
setConfirmDialog({
isOpen: true,
type: 'spam',
title: '스팸 신고',
message: '이 메일을 스팸으로 신고하고 스팸함으로 이동하시겠습니까?',
confirmText: '신고',
onConfirm: async () => {
if (selectedEmail) {
await moveToSpam(selectedEmail.id, selectedBox);
toast.success('스팸함으로 이동했습니다.');
}
}
});
}}
className="p-2.5 text-gray-500 hover:text-orange-500 hover:bg-white rounded-lg transition-all"
>
<AlertOctagon size={18} />
</button>
</Tooltip>
)}
{/* 스팸함에서 받은편지함으로 이동 */}
{selectedBox === 'SPAM' && (
<Tooltip title="받은편지함으로 이동" arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => {
setConfirmDialog({
isOpen: true,
type: 'restore',
title: '받은편지함으로 이동',
message: '이 메일을 받은편지함으로 이동하시겠습니까?',
confirmText: '이동',
onConfirm: async () => {
if (selectedEmail) {
await moveEmail(selectedEmail.id, selectedBox, 'INBOX');
setSelectedEmail(null);
toast.success('받은편지함으로 이동했습니다.');
}
}
});
}}
className="p-2.5 text-gray-500 hover:text-blue-500 hover:bg-white rounded-lg transition-all"
>
<MailIcon size={18} />
</button>
</Tooltip>
)}
<Tooltip title={isStarred ? "중요 표시 해제" : "중요 표시"} arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={handleToggleStar}
className={`p-2.5 rounded-lg transition-all ${isStarred ? 'text-amber-400 hover:bg-white' : 'text-gray-500 hover:text-amber-400 hover:bg-white'}`}
>
<Star size={18} className={isStarred ? 'fill-amber-400' : ''} />
</button>
</Tooltip>
<Tooltip title="인쇄" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handlePrint} className="p-2.5 text-gray-500 hover:text-gray-700 hover:bg-white rounded-lg transition-all">
<Printer size={18} />
</button>
</Tooltip>
<Tooltip title={showTranslation ? "원본 보기" : "한국어로 번역"} arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => showTranslation ? toggleTranslation() : handleTranslate('ko')}
disabled={isTranslating}
className={`p-2.5 rounded-lg transition-all ${showTranslation ? 'text-amber-500 bg-white' : 'text-gray-500 hover:text-amber-500 hover:bg-white'} ${isTranslating ? 'animate-pulse' : ''}`}
>
<Languages size={18} />
</button>
</Tooltip>
</>
)}
</>
)}
</div>
</div>
{/* 스크롤 가능한 콘텐츠 (인쇄 영역) */}
<div className="flex-1 overflow-y-auto" id="print-area">
<div className="px-8 py-6">
{/* 제목 행 */}
<div className="flex items-start justify-between mb-6">
<h2 className="text-xl font-bold text-gray-800 flex-1 leading-tight">
{isSearchMode && searchQuery ? (
<HighlightText text={decodeHtmlEntities(selectedEmail.subject) || '(제목 없음)'} query={searchQuery} />
) : (
decodeHtmlEntities(selectedEmail.subject) || '(제목 없음)'
)}
</h2>
</div>
{/* 보낸 사람 / 받는 사람 정보 카드 */}
<div className="flex items-center justify-between mb-6 p-4 bg-gray-50 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-lg shadow-lg shadow-blue-500/30">
{selectedEmail.mailbox === 'DRAFTS'
? (selectedEmail.to ? selectedEmail.to.charAt(0).toUpperCase() : '?')
: name.charAt(0).toUpperCase()
}
</div>
<div>
{selectedEmail.mailbox === 'DRAFTS' ? (
<>
<p className="text-xs text-gray-500 mb-0.5">받는 사람</p>
<p className="text-sm font-semibold text-gray-800">{selectedEmail.to || '(받는 사람 없음)'}</p>
</>
) : (
<>
<p className="text-sm font-semibold text-gray-800">{name}</p>
<p className="text-xs text-gray-500">{email || selectedEmail.from}</p>
</>
)}
</div>
</div>
<p className="text-xs text-gray-400 font-medium">
{formatDate(selectedEmail.date)}
</p>
</div>
{/* 첨부파일 */}
{selectedEmail.attachments && Array.isArray(selectedEmail.attachments) && selectedEmail.attachments.length > 0 && (
<div className="mb-6">
<h4 className="flex items-center text-sm font-semibold text-gray-700 mb-3">
<Paperclip size={16} className="mr-2" />
첨부파일 ({selectedEmail.attachments.length})
</h4>
<div className="flex flex-wrap gap-2">
{selectedEmail.attachments.map((att, idx) => (
<button
key={idx}
onClick={() => handleDownload(att.filename)}
className="flex items-center gap-2 bg-white border border-gray-200 rounded-xl px-4 py-2.5 hover:border-blue-300 hover:bg-blue-50 transition-all group"
>
{getFileIcon(att.filename)}
<span className="text-sm text-gray-600 group-hover:text-blue-600 truncate max-w-[180px]">{att.filename}</span>
<span className="text-xs text-gray-400">({(att.size / 1024).toFixed(1)} KB)</span>
<Download size={14} className="text-gray-400 group-hover:text-blue-500" />
</button>
))}
</div>
</div>
)}
{/* 이메일 본문 */}
{(() => {
//
const renderOriginalContent = () => {
const htmlContent = selectedEmail.html || selectedEmail.body;
const textContent = selectedEmail.text;
const textHasHtml = textContent && /<[a-z][\s\S]*>/i.test(textContent);
if (htmlContent) {
return (
<div
className="email-html-content mb-8 overflow-x-auto"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
} else if (textHasHtml) {
return (
<div
className="email-html-content mb-8 overflow-x-auto"
dangerouslySetInnerHTML={{ __html: textContent }}
/>
);
} else {
return (
<div className="mb-8 p-6 bg-gray-50 rounded-xl border border-gray-100">
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed">
{linkifyText(decodeHtmlEntities(textContent) || '내용 없음')}
</pre>
</div>
);
}
};
// : +
if (isTranslating) {
return (
<>
<div className="mb-4 p-4 bg-gradient-to-r from-blue-50 via-purple-50 to-rose-50 rounded-xl border border-gray-200 shadow-sm flex items-center gap-4">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shadow-lg" style={{ background: 'linear-gradient(135deg, #4285f4 0%, #ea4335 33%, #fbbc05 66%, #34a853 100%)' }}>
<Sparkles className="w-5 h-5 text-white" />
</div>
<div className="absolute -top-1 -right-1 w-3 h-3 bg-blue-500 rounded-full animate-ping" />
</div>
<div className="flex-1">
<div className="text-gray-800 font-semibold flex items-center gap-2">
번역 중입니다...
<span className="text-xs font-medium px-2 py-0.5 rounded-full text-white" style={{ background: 'linear-gradient(90deg, #4285f4, #ea4335)' }}>
{usedModel || 'Gemini AI'}
</span>
</div>
<div className="text-gray-500 text-sm">AI가 이메일 내용을 분석하고 있습니다</div>
</div>
<div className="w-6 h-6 border-3 border-blue-200 border-t-blue-500 rounded-full animate-spin flex-shrink-0" />
</div>
{renderOriginalContent()}
</>
);
}
// :
if (showTranslation && translatedContent) {
const isHtml = /<[a-z][\s\S]*>/i.test(translatedContent);
return (
<div className="relative">
<div className="absolute -top-2 left-4 px-3 py-1 rounded-full text-xs font-semibold text-white shadow-md flex items-center gap-1.5" style={{ background: 'linear-gradient(90deg, #4285f4, #ea4335, #fbbc05, #34a853)' }}>
<Sparkles className="w-3 h-3" />
번역됨
</div>
{isHtml ? (
<div
className="email-html-content mb-8 overflow-x-auto rounded-xl p-4 mt-2"
style={{ border: '2px solid transparent', background: 'linear-gradient(white, white) padding-box, linear-gradient(90deg, #4285f4, #ea4335, #fbbc05, #34a853) border-box' }}
dangerouslySetInnerHTML={{ __html: translatedContent }}
/>
) : (
<div
className="mb-8 p-6 rounded-xl mt-2"
style={{ border: '2px solid transparent', background: 'linear-gradient(#f0f9ff, #f0f9ff) padding-box, linear-gradient(90deg, #4285f4, #ea4335, #fbbc05, #34a853) border-box' }}
>
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed">
{translatedContent}
</pre>
</div>
)}
</div>
);
}
// :
return renderOriginalContent();
})()}
{/* 하단 버튼 */}
<div className="flex items-center gap-3 pb-6">
{selectedEmail.mailbox === 'DRAFTS' ? (
// :
<Button
variant="contained"
startIcon={<Edit size={16} />}
onClick={() => onContinueDraft && onContinueDraft(selectedEmail)}
sx={{
borderRadius: '12px',
textTransform: 'none',
background: 'linear-gradient(to right, #3b82f6, #6366f1)',
boxShadow: '0 4px 14px 0 rgba(59, 130, 246, 0.3)',
px: 4,
py: 1.2,
'&:hover': {
background: 'linear-gradient(to right, #2563eb, #4f46e5)',
boxShadow: '0 6px 20px 0 rgba(59, 130, 246, 0.4)'
}
}}
>
이어서 작성
</Button>
) : (
// : /
<>
<button
onClick={() => onReply && onReply(selectedEmail)}
className="flex items-center gap-2 px-5 py-2.5 bg-white border border-gray-200 text-gray-700 rounded-xl font-medium shadow-sm hover:shadow-md hover:border-blue-300 hover:text-blue-600 hover:bg-blue-50/50 transition-all duration-200 group"
>
<Reply size={16} className="group-hover:scale-110 transition-transform" />
<span>답장</span>
</button>
<button
onClick={() => onForward && onForward(selectedEmail)}
className="flex items-center gap-2 px-5 py-2.5 bg-white border border-gray-200 text-gray-700 rounded-xl font-medium shadow-sm hover:shadow-md hover:border-indigo-300 hover:text-indigo-600 hover:bg-indigo-50/50 transition-all duration-200 group"
>
<Forward size={16} className="group-hover:scale-110 transition-transform" />
<span>전달</span>
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* 확인 다이얼로그 */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
onConfirm={confirmDialog.onConfirm}
title={confirmDialog.title}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
type={confirmDialog.type}
/>
</>
);
};
export default MailDetail;

View file

@ -0,0 +1,737 @@
/**
* 메일 목록 컴포넌트
* 메일 목록 표시, 선택, 페이지네이션, 액션 버튼
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useMail } from '../context/MailContext';
import { Square, RotateCw, Check, Archive, Trash2, Mail, MailOpen, Minus, Paperclip, ChevronLeft, ChevronRight, MoreVertical, FolderInput, Star, AlertOctagon } from 'lucide-react';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
import { useNavigate } from 'react-router-dom';
import { decodeHtmlEntities } from '../utils/decodeHtmlEntities';
import { encodeEmailId } from '../utils/emailIdEncoder';
import { HighlightText } from '../utils/highlightText';
import toast from 'react-hot-toast';
import ConfirmDialog from './ConfirmDialog';
const MailList = () => {
const { emails, loading, selectedEmail, fetchEmails, selectedBox, markAsRead, markAllAsRead, markAsUnread, moveToTrash, deleteEmail, restoreEmail, page, totalPages, totalEmails, moveEmail, deleteAllEmails, isSearchMode, searchResults, searchTotal, searchQuery, searchPage, searchTotalPages, searchEmails, searchScope, searchFilters, clearSearch } = useMail();
const navigate = useNavigate();
const [checkedEmailIds, setCheckedEmailIds] = useState(new Set());
// searchResults, emails
const displayEmails = isSearchMode ? searchResults : emails;
const displayTotal = isSearchMode ? searchTotal : totalEmails;
//
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
const moreMenuRef = useRef(null);
const moveMenuRef = useRef(null);
//
const [confirmDialog, setConfirmDialog] = useState({ isOpen: false, type: '', title: '', message: '', onConfirm: () => {} });
//
useEffect(() => {
setCheckedEmailIds(new Set());
}, [selectedBox]);
// ( )
// , ( )
useEffect(() => {
if (!selectedEmail && !isSearchMode) {
setCheckedEmailIds(new Set());
}
}, [selectedEmail, isSearchMode]);
//
useEffect(() => {
const handleClickOutside = (e) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) {
setMoreMenuOpen(false);
}
if (moveMenuRef.current && !moveMenuRef.current.contains(e.target)) {
setMoveMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleNextPage = () => {
if (isSearchMode) {
// :
if (searchPage < searchTotalPages) {
searchEmails(searchQuery, searchScope, searchFilters, searchPage + 1);
}
} else {
// :
if (page < totalPages) {
fetchEmails(selectedBox, page + 1);
}
}
};
const handlePrevPage = () => {
if (isSearchMode) {
// :
if (searchPage > 1) {
searchEmails(searchQuery, searchScope, searchFilters, searchPage - 1);
}
} else {
// :
if (page > 1) {
fetchEmails(selectedBox, page - 1);
}
}
};
const LIMIT = 20;
// paginationText useMemo
const paginationText = useMemo(() => {
if (isSearchMode) {
const currentPage = searchPage || 1;
const start = (currentPage - 1) * LIMIT + 1;
const end = Math.min(currentPage * LIMIT, displayTotal);
return displayTotal > 0
? `검색 결과: ${displayTotal.toLocaleString()}개 중 ${start}-${end}`
: '검색 결과: 0개';
}
const start = (page - 1) * LIMIT + 1;
const end = Math.min(page * LIMIT, displayTotal);
const text = displayTotal > 0
? `${displayTotal.toLocaleString()}개 중 ${start}-${end}`
: '0개';
return text;
}, [displayTotal, page, searchPage, isSearchMode]);
const handleRefresh = () => {
fetchEmails(selectedBox, page);
};
const toggleCheck = (e, id) => {
e.stopPropagation();
const newSet = new Set(checkedEmailIds);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setCheckedEmailIds(newSet);
};
const toggleAll = () => {
if (checkedEmailIds.size === displayEmails.length && displayEmails.length > 0) {
setCheckedEmailIds(new Set());
} else {
setCheckedEmailIds(new Set(displayEmails.map(e => e.id)));
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const diffSeconds = Math.floor(diff / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 60) return `${diffSeconds}초 전`;
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
const year = date.getFullYear();
const currentYear = now.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (year === currentYear) return `${month}${day}`;
return `${year}${month}${day}`;
};
// fromName , from
const extractSenderName = (email) => {
if (!email) return '(발신자 없음)';
// 1: fromName
if (email.fromName) {
return email.fromName;
}
const fromStr = email.from;
if (!fromStr) return '(발신자 없음)';
// 2: "" <email>
const match = fromStr.match(/^(.*?)\s*<.*?>$/);
if (match) {
return match[1].replace(/['"]/g, '').trim() || fromStr.split('@')[0];
}
// 3: @
if (fromStr.includes('@')) {
return fromStr.split('@')[0];
}
return fromStr;
};
const selectedCount = checkedEmailIds.size;
const isAllSelected = displayEmails.length > 0 && selectedCount === displayEmails.length;
const isIndeterminate = selectedCount > 0 && selectedCount < displayEmails.length;
const isEmailRead = (email) => {
return email.isRead || (email.flags && email.flags.includes('\\Seen'));
};
const selectedEmailsList = displayEmails.filter(e => checkedEmailIds.has(e.id));
const hasUnreadInSelection = selectedEmailsList.some(e => !isEmailRead(e));
const markAsReadIcon = hasUnreadInSelection ? <MailOpen className="h-5 w-5" /> : <Mail className="h-5 w-5" />;
const handleMarkAsReadSelection = async () => {
if (hasUnreadInSelection) {
if (isSearchMode) {
// :
const byMailbox = {};
selectedEmailsList.forEach(email => {
const mailbox = email.mailbox || 'INBOX';
if (!byMailbox[mailbox]) byMailbox[mailbox] = [];
byMailbox[mailbox].push(email.id);
});
for (const [mailbox, ids] of Object.entries(byMailbox)) {
await markAllAsRead(ids, mailbox);
}
} else {
const ids = Array.from(checkedEmailIds);
await markAllAsRead(ids, selectedBox);
}
} else {
for (const email of selectedEmailsList) {
await markAsUnread(email.id);
}
}
};
const handleDeleteSelection = () => {
const count = selectedEmailsList.length;
//
const hasTrashEmails = isSearchMode
? selectedEmailsList.some(e => (e.mailbox || 'INBOX') === 'TRASH')
: selectedBox === 'TRASH';
const allFromTrash = isSearchMode
? selectedEmailsList.every(e => (e.mailbox || 'INBOX') === 'TRASH')
: selectedBox === 'TRASH';
if (allFromTrash) {
//
setConfirmDialog({
isOpen: true,
type: 'danger',
title: '영구 삭제',
message: `${count}개의 메일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`,
confirmText: '영구 삭제',
onConfirm: async () => {
for (const email of selectedEmailsList) {
const mailbox = email.mailbox || selectedBox;
await deleteEmail(email.id, mailbox);
}
toast.success(`${count}개의 메일이 영구 삭제되었습니다.`);
setCheckedEmailIds(new Set());
}
});
} else if (hasTrashEmails && isSearchMode) {
// +
setConfirmDialog({
isOpen: true,
type: 'danger',
title: '메일 삭제',
message: `${count}개의 메일 중 휴지통 메일은 영구 삭제되고, 나머지는 휴지통으로 이동됩니다. 계속하시겠습니까?`,
confirmText: '삭제',
onConfirm: async () => {
const trashIds = [];
for (const email of selectedEmailsList) {
const mailbox = email.mailbox || 'INBOX';
if (mailbox === 'TRASH') {
await deleteEmail(email.id, 'TRASH');
} else {
const result = await moveToTrash(email.id, mailbox);
if (result?.trashId) {
trashIds.push(result.trashId);
}
}
}
if (trashIds.length > 0) {
toast((t) => (
<div className="flex items-center gap-3">
<span>{count}개의 메일이 처리되었습니다.</span>
<button
onClick={async () => {
toast.dismiss(t.id);
for (const trashId of trashIds) {
await restoreEmail(trashId, 'TRASH');
}
toast.success('복구되었습니다.');
}}
className="text-blue-500 font-medium hover:text-blue-600"
>
실행취소
</button>
</div>
), { duration: 5000 });
} else {
toast.success(`${count}개의 메일이 처리되었습니다.`);
}
setCheckedEmailIds(new Set());
}
});
} else {
//
setConfirmDialog({
isOpen: true,
type: 'danger',
title: '메일 삭제',
message: `${count}개의 메일을 휴지통으로 이동하시겠습니까?`,
confirmText: '삭제',
onConfirm: async () => {
const trashIds = [];
for (const email of selectedEmailsList) {
const mailbox = isSearchMode ? (email.mailbox || 'INBOX') : selectedBox;
const result = await moveToTrash(email.id, mailbox);
if (result?.trashId) {
trashIds.push(result.trashId);
}
}
if (trashIds.length > 0) {
toast((t) => (
<div className="flex items-center gap-3">
<span>{count}개의 메일이 휴지통으로 이동되었습니다.</span>
<button
onClick={async () => {
toast.dismiss(t.id);
for (const trashId of trashIds) {
await restoreEmail(trashId, 'TRASH');
}
toast.success('복구되었습니다.');
}}
className="text-blue-500 font-medium hover:text-blue-600"
>
실행취소
</button>
</div>
), { duration: 5000 });
} else {
toast.success(`${count}개의 메일이 휴지통으로 이동되었습니다.`);
}
setCheckedEmailIds(new Set());
}
});
}
};
// -
const handleMoveSelection = (target) => {
const count = selectedEmailsList.length;
const targetNames = {
'IMPORTANT': '중요편지함',
'SPAM': '스팸함',
'TRASH': '휴지통',
'INBOX': '받은편지함'
};
// spam , danger, warning
const dialogType = target === 'SPAM' ? 'spam' : target === 'TRASH' ? 'danger' : 'warning';
setConfirmDialog({
isOpen: true,
type: dialogType,
title: `${targetNames[target]}으로 이동`,
message: `${count}개의 메일을 ${targetNames[target]}으로 이동하시겠습니까?`,
confirmText: '이동',
onConfirm: async () => {
// moveToTrash ( )
if (target === 'TRASH') {
const trashIds = [];
for (const email of selectedEmailsList) {
const mailbox = isSearchMode ? (email.mailbox || 'INBOX') : selectedBox;
const result = await moveToTrash(email.id, mailbox);
if (result?.trashId) {
trashIds.push(result.trashId);
}
}
if (trashIds.length > 0) {
toast((t) => (
<div className="flex items-center gap-3">
<span>{count}개의 메일이 휴지통으로 이동되었습니다.</span>
<button
onClick={async () => {
toast.dismiss(t.id);
for (const trashId of trashIds) {
await restoreEmail(trashId, 'TRASH');
}
toast.success('복구되었습니다.');
}}
className="text-blue-500 font-medium hover:text-blue-600"
>
실행취소
</button>
</div>
), { duration: 5000 });
} else {
toast.success(`${count}개의 메일이 휴지통으로 이동되었습니다.`);
}
} else {
// moveEmail
for (const email of selectedEmailsList) {
const mailbox = isSearchMode ? (email.mailbox || 'INBOX') : selectedBox;
await moveEmail(email.id, mailbox, target);
}
toast.success(`${count}개의 메일이 ${targetNames[target]}으로 이동되었습니다.`);
}
setCheckedEmailIds(new Set());
}
});
setMoveMenuOpen(false);
};
const handleRestoreSelection = async () => {
const count = selectedEmailsList.length;
for (const email of selectedEmailsList) {
const mailbox = isSearchMode ? (email.mailbox || 'TRASH') : selectedBox;
await restoreEmail(email.id, mailbox);
}
setCheckedEmailIds(new Set());
toast.success(`${count}개의 메일이 복구되었습니다.`);
};
// -
const handleDeleteAll = () => {
const title = selectedBox === 'TRASH' ? '휴지통 비우기' : '전체 삭제';
const message = selectedBox === 'TRASH'
? '휴지통을 비우시겠습니까? 모든 메일이 영구 삭제됩니다.'
: `${selectedBox === 'INBOX' ? '받은편지함' : selectedBox}의 모든 메일을 휴지통으로 이동하시겠습니까?`;
setConfirmDialog({
isOpen: true,
type: 'danger',
title,
message,
confirmText: selectedBox === 'TRASH' ? '비우기' : '삭제',
onConfirm: async () => {
await deleteAllEmails(selectedBox);
toast.success(selectedBox === 'TRASH' ? '휴지통이 비워졌습니다.' : '모든 메일이 휴지통으로 이동되었습니다.');
}
});
setMoreMenuOpen(false);
};
const handleEmailClick = (email) => {
//
const mailbox = email.mailbox || selectedBox;
navigate(`/mail/${mailbox.toLowerCase()}/${encodeEmailId(email.id)}`);
};
//
const hasAttachments = (email) => {
let atts = email.attachments;
if (typeof atts === 'string') {
try { atts = JSON.parse(atts); } catch { atts = []; }
}
return Array.isArray(atts) && atts.length > 0;
};
// (HTML, base64, data URL )
const cleanPreviewText = (text) => {
if (!text) return '';
return text
.replace(/<[^>]*>/g, '') // HTML
.replace(/&nbsp;/g, ' ') // nbsp
.replace(/&[a-z]+;/gi, '') // HTML
.replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/gi, '') // data URL
.replace(/[A-Za-z0-9+/=]{50,}/g, '') // base64
.replace(/\s+/g, ' ') //
.trim()
.substring(0, 100);
};
return (
<>
<div className="flex flex-col h-full bg-gray-50">
{/* 툴바 */}
<div className="flex-none h-14 px-4 bg-white border-b border-gray-100 flex items-center justify-between">
{selectedCount > 0 ? (
<div className="flex items-center w-full animate-fade-in">
<div className="flex items-center flex-1 min-w-0">
<Tooltip title="선택 취소" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={toggleAll} className="hover:bg-gray-100 rounded transition-all">
<div className={`w-5 h-5 rounded border flex items-center justify-center ${
isAllSelected || isIndeterminate
? 'bg-blue-500 border-blue-500'
: 'border-gray-400'
}`}>
{isIndeterminate && <Minus className="h-3 w-3 text-white" strokeWidth={3} />}
{isAllSelected && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</div>
</button>
</Tooltip>
<span className="text-sm ml-2 font-semibold text-blue-600 select-none whitespace-nowrap">
{selectedCount} 선택됨
</span>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-xl p-1">
{/* 임시보관함에서는 읽음표시 버튼 숨기기 */}
{selectedBox !== 'DRAFTS' && (
<Tooltip title={hasUnreadInSelection ? '읽은 상태로 표시' : '읽지 않은 상태로 표시'} arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleMarkAsReadSelection} className="p-2 hover:bg-white rounded-lg transition-all text-gray-600 hover:text-blue-500">
{markAsReadIcon}
</button>
</Tooltip>
)}
{selectedBox === 'TRASH' && (
<Tooltip title="복구" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleRestoreSelection} className="p-2 hover:bg-white rounded-lg transition-all text-gray-600 hover:text-emerald-500">
<Archive className="h-5 w-5" />
</button>
</Tooltip>
)}
{/* 이동 메뉴 (휴지통/임시보관함 제외) */}
{selectedBox !== 'TRASH' && selectedBox !== 'DRAFTS' && (
<div className="relative" ref={moveMenuRef}>
<Tooltip title="이동" arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => setMoveMenuOpen(!moveMenuOpen)}
className="p-2 hover:bg-white rounded-lg transition-all text-gray-600 hover:text-violet-500"
>
<FolderInput className="h-5 w-5" />
</button>
</Tooltip>
{moveMenuOpen && (
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden z-50 min-w-[140px]">
{/* 스팸함/중요편지함에서는 받은편지함으로 이동 */}
{(selectedBox === 'IMPORTANT' || selectedBox === 'SPAM') && (
<button
onClick={() => handleMoveSelection('INBOX')}
className="w-full px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600 flex items-center gap-2"
>
<Mail size={16} />
받은편지함
</button>
)}
{selectedBox !== 'IMPORTANT' && selectedBox !== 'SPAM' && (
<button
onClick={() => handleMoveSelection('IMPORTANT')}
className="w-full px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-amber-50 hover:text-amber-600 flex items-center gap-2"
>
<Star size={16} />
중요편지함
</button>
)}
{selectedBox !== 'SPAM' && (
<button
onClick={() => handleMoveSelection('SPAM')}
className="w-full px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-orange-50 hover:text-orange-600 flex items-center gap-2"
>
<AlertOctagon size={16} />
스팸함
</button>
)}
<button
onClick={() => handleMoveSelection('TRASH')}
className="w-full px-4 py-2.5 text-left text-sm text-gray-700 hover:bg-red-50 hover:text-red-600 flex items-center gap-2"
>
<Trash2 size={16} />
휴지통
</button>
</div>
)}
</div>
)}
{/* 휴지통/임시보관함에서만 삭제 버튼 표시 */}
{(selectedBox === 'TRASH' || selectedBox === 'DRAFTS') && (
<Tooltip title={selectedBox === 'TRASH' ? '영구 삭제' : '삭제'} arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleDeleteSelection} className="p-2 hover:bg-white rounded-lg transition-all text-gray-600 hover:text-red-500">
<Trash2 className="h-5 w-5" />
</button>
</Tooltip>
)}
</div>
</div>
) : (
<div className="flex items-center w-full">
<div className="flex items-center gap-2">
<Tooltip title="전체 선택" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={toggleAll} className="hover:bg-gray-100 rounded transition-all">
<div className="w-5 h-5 rounded border border-gray-400 flex items-center justify-center">
</div>
</button>
</Tooltip>
<Tooltip title="새로고침" arrow placement="bottom" TransitionComponent={Fade}>
<button onClick={handleRefresh} className="p-2 hover:bg-gray-100 rounded-lg transition-all text-gray-500 ml-1S">
<RotateCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</Tooltip>
{/* 더보기 메뉴 */}
<div className="relative" ref={moreMenuRef}>
<Tooltip title="더보기" arrow placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => setMoreMenuOpen(!moreMenuOpen)}
className="p-2 hover:bg-gray-100 rounded-lg transition-all text-gray-500"
>
<MoreVertical className="h-5 w-5" />
</button>
</Tooltip>
{moreMenuOpen && (
<div className="absolute left-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden z-50 min-w-[120px]">
<button
onClick={handleDeleteAll}
className="w-full px-4 py-2.5 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 size={16} />
전체 삭제
</button>
</div>
)}
</div>
</div>
<div className="flex-1"></div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span className="font-medium">{paginationText}</span>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={handlePrevPage}
disabled={isSearchMode ? searchPage <= 1 : page <= 1}
className="p-1.5 hover:bg-white rounded-md transition-all disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={handleNextPage}
disabled={isSearchMode ? searchPage >= searchTotalPages : page >= totalPages}
className="p-1.5 hover:bg-white rounded-md transition-all disabled:opacity-40 disabled:hover:bg-transparent"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
)}
</div>
{/* 목록 */}
<div className="flex-1 overflow-y-auto relative">
{displayEmails.length > 0 ? (
<ul className="divide-y divide-gray-100">
{displayEmails.map((email) => {
const isRead = isEmailRead(email);
const isSelected = selectedEmail?.id === email.id;
const isChecked = checkedEmailIds.has(email.id);
const emailMailbox = email.mailbox || selectedBox;
// DRAFTS ,
const displayName = emailMailbox === 'DRAFTS'
? (email.to || '(받는 사람 없음)')
: extractSenderName(email);
return (
<li
key={email.id}
onClick={() => handleEmailClick(email)}
className={`relative px-4 py-3.5 cursor-pointer transition-all duration-150 group min-h-[88px]
${!isRead ? 'bg-blue-50' : 'bg-white'}
hover:bg-gray-50
`}
>
{/* 선택된 메일 왼쪽 파란색 세로줄 */}
{isSelected && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500" />
)}
<div className="flex items-center gap-3">
{/* 체크박스 */}
<div
onClick={(e) => toggleCheck(e, email.id)}
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-all
${isChecked ? 'bg-blue-500 border-blue-500 scale-110' : 'border-gray-300 group-hover:border-blue-400'}
`}
>
{isChecked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
</div>
{/* 아바타 */}
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-semibold flex-shrink-0 shadow-sm
${!isRead ? 'bg-gradient-to-br from-blue-500 to-indigo-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}
`}>
{(displayName.split('<')[0].trim() || displayName).charAt(0).toUpperCase()}
</div>
{/* 메일 정보 */}
<div className="flex-1 min-w-0">
{/* 첫 번째 줄: 발신자 + 날짜 */}
<div className="flex items-center justify-between gap-2 mb-0.5">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={`truncate text-sm ${!isRead ? 'text-gray-900 font-semibold' : 'text-gray-600 font-medium'}`}>
{isSearchMode && searchQuery ? (
<HighlightText text={decodeHtmlEntities(displayName.split('<')[0].trim() || displayName)} query={searchQuery} />
) : (
decodeHtmlEntities(displayName.split('<')[0].trim() || displayName)
)}
</span>
{hasAttachments(email) && (
<Paperclip className="h-3.5 w-3.5 text-gray-400 flex-shrink-0" />
)}
</div>
<span className={`text-xs flex-shrink-0 ${!isRead ? 'text-blue-600 font-medium' : 'text-gray-400'}`}>
{formatDate(email.date)}
</span>
</div>
{/* 두 번째 줄: 제목 */}
<div className={`text-sm truncate ${!isRead ? 'text-gray-800 font-medium' : 'text-gray-600'}`}>
{isSearchMode && searchQuery ? (
<HighlightText text={decodeHtmlEntities(email.subject) || '(제목 없음)'} query={searchQuery} />
) : (
decodeHtmlEntities(email.subject) || '(제목 없음)'
)}
</div>
{/* 세 번째 줄: 미리보기 */}
<div className="text-xs text-gray-400 truncate mt-0.5 leading-relaxed">
{isSearchMode && searchQuery ? (
<HighlightText text={decodeHtmlEntities(cleanPreviewText(email.text))} query={searchQuery} />
) : (
decodeHtmlEntities(cleanPreviewText(email.text))
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
) : (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<Mail size={48} className="mb-4 opacity-40" />
<p className="text-lg font-medium text-gray-500">메일이 없습니다</p>
<p className="text-sm text-gray-400 mt-1">새로운 메일이 도착하면 여기에 표시됩니다</p>
</div>
)}
</div>
</div>
{/* 확인 다이얼로그 */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
onConfirm={confirmDialog.onConfirm}
title={confirmDialog.title}
message={confirmDialog.message}
confirmText={confirmDialog.confirmText}
type={confirmDialog.type}
/>
</>
);
};
export default MailList;

View file

@ -0,0 +1,171 @@
/**
* 사이드바 컴포넌트
* 메일함 네비게이션, 메일 쓰기 버튼, 저장공간 표시
*/
import React from 'react';
import { useMail } from '../context/MailContext';
import { Inbox, Send, File, Trash, Mail, Pencil, AlertOctagon, Star, ChevronRight, Search, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
const Sidebar = ({ onComposeClick, activeBox }) => {
const { selectedBox, counts, isSearchMode, searchTotal, searchQuery, clearSearch, setSelectedEmail } = useMail();
const navigate = useNavigate();
const currentBox = isSearchMode ? 'SEARCH' : (activeBox || selectedBox);
const formatSize = (mb) => {
const size = parseFloat(mb);
if (isNaN(size)) return '0.00 MB';
if (size >= 1024) return `${(size / 1024).toFixed(2)} GB`;
return `${size.toFixed(2)} MB`;
};
const navItems = [
{ name: 'INBOX', icon: Mail, label: '받은편지함', color: 'from-blue-500 to-blue-600' },
{ name: 'SENT', icon: Send, label: '보낸편지함', color: 'from-emerald-500 to-emerald-600' },
{ name: 'IMPORTANT', icon: Star, label: '중요편지함', color: 'from-amber-500 to-amber-600' },
{ name: 'DRAFTS', icon: File, label: '임시보관함', color: 'from-slate-400 to-slate-500' },
{ name: 'SPAM', icon: AlertOctagon, label: '스팸함', color: 'from-orange-500 to-orange-600' },
{ name: 'TRASH', icon: Trash, label: '휴지통', color: 'from-red-400 to-red-500' },
];
const handleClick = (boxName) => {
//
if (isSearchMode) {
clearSearch();
}
navigate(`/mail/${boxName.toLowerCase()}`);
};
// X
const handleCloseSearch = (e) => {
e.stopPropagation();
clearSearch();
setSelectedEmail(null); //
navigate('/mail/inbox'); //
};
const storageUsed = counts?.storageUsed || 0;
const storageLimit = counts?.storageLimit || 51200;
const storagePercent = Math.min((storageUsed / storageLimit) * 100, 100);
return (
<div className="flex flex-col h-full bg-white">
{/* 로고 영역 */}
<div className="px-6 py-6">
<div className="flex items-center gap-3">
<div className="w-11 h-11 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-blue-500/30">
<Mail className="text-white" size={22} />
</div>
<div>
<h1 className="text-slate-800 font-bold text-lg">Mailbox</h1>
<p className="text-slate-400 text-xs">메일 서비스</p>
</div>
</div>
</div>
{/* 메일 쓰기 버튼 */}
<div className="px-4 mb-4">
<button
onClick={onComposeClick}
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 bg-gradient-to-r from-blue-500 to-indigo-600 text-white font-semibold rounded-xl shadow-lg shadow-blue-500/30 hover:shadow-xl hover:shadow-blue-500/40 hover:-translate-y-0.5 transition-all"
>
<Pencil size={18} />
<span>메일 쓰기</span>
</button>
</div>
{/* 구분선 + 섹션 라벨 */}
<div className="px-6 mb-2 mt-2">
<div className="border-t border-gray-100 pt-4">
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">메일함</span>
</div>
</div>
{/* 네비게이션 */}
<nav className="flex-1 px-4 overflow-y-auto">
<div className="space-y-1">
{/* 검색 결과 항목 - 검색 모드일 때만 표시 */}
{isSearchMode && (
<div className="mb-3">
<div
className="w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all bg-gradient-to-r from-purple-500 to-indigo-600 text-white shadow-lg"
>
<div className="flex items-center gap-3">
<Search size={20} className="text-white" />
<div className="text-left">
<span className="text-sm font-medium block">검색 결과</span>
<span className="text-xs text-white/70 truncate max-w-[120px] block">"{searchQuery}"</span>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-white/20 text-white">
{searchTotal}
</span>
<button
onClick={handleCloseSearch}
className="p-1 hover:bg-white/20 rounded-lg transition-colors"
title="검색 닫기"
>
<X size={14} className="text-white" />
</button>
</div>
</div>
<div className="border-b border-gray-100 my-3"></div>
</div>
)}
{navItems.map((item) => {
const Icon = item.icon;
const isActive = !isSearchMode && currentBox === item.name;
const count = counts ? (counts[item.name] || 0) : 0;
return (
<button
key={item.name}
onClick={() => handleClick(item.name)}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all ${
isActive
? `bg-gradient-to-r ${item.color} text-white shadow-lg`
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-3">
<Icon size={20} className={isActive ? 'text-white' : 'text-slate-400'} />
<span className="text-sm font-medium">{item.label}</span>
</div>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-semibold ${
isActive
? 'bg-white/20 text-white'
: 'bg-slate-100 text-slate-500'
}`}>
{count}
</span>
</button>
);
})}
</div>
</nav>
{/* 저장 용량 위젯 */}
<div className="px-4 pb-4 mt-auto">
<div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl p-5 border border-slate-100">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold text-slate-700">저장공간</h3>
<span className="text-xs font-medium text-blue-500">{storagePercent.toFixed(1)}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 mb-3 overflow-hidden">
<div
className="bg-gradient-to-r from-blue-500 to-indigo-500 h-2 rounded-full transition-all"
style={{ width: `${storagePercent}%` }}
/>
</div>
<p className="text-xs text-slate-500 font-medium">
{formatSize(storageUsed)} / {formatSize(storageLimit)}
</p>
</div>
</div>
</div>
);
};
export default Sidebar;

View file

@ -0,0 +1,660 @@
/**
* 관리자 대시보드 컴포넌트
* 통계 카드, 트래픽 그래프, 유저별 통계, Remote IPs, 시스템 상태 표시
*/
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useMail } from '../../context/MailContext';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer
} from 'recharts';
import { Users, Database, Send, Inbox, Server, Clock, HardDrive, ArrowDown, ArrowUp, Globe, ShieldAlert } from 'lucide-react';
/**
* 상대 시간 포맷 함수 (몇초 , 몇분 , 몇시간 )
*/
const formatRelativeTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const diffSeconds = Math.floor(diff / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 60) return `${diffSeconds}초 전`;
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
const year = date.getFullYear();
const currentYear = now.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (year === currentYear) return `${month}${day}`;
return `${year}${month}${day}`;
};
/**
* 한국어 날짜 포맷 함수 (0000 00 00 오전/오후 00 00)
*/
const formatKoreanDate = (dateString) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
const ampm = hours < 12 ? '오전' : '오후';
const displayHours = String(hours % 12 || 12).padStart(2, '0');
return `${year}${month}${day}${ampm} ${displayHours}${minutes}`;
};
/**
* 커스텀 툴팁 컴포넌트
*/
const IpTooltip = ({ item, children }) => {
const [show, setShow] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const containerRef = useRef(null);
const handleMouseEnter = (e) => {
const rect = e.currentTarget.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.bottom + 8 });
setShow(true);
};
return (
<div
ref={containerRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShow(false)}
className="relative"
>
{children}
{show && (
<div
className="fixed z-50 bg-gray-800 text-white text-sm rounded-xl shadow-2xl p-4 min-w-[260px] max-w-[350px]"
style={{ left: position.x, top: position.y }}
>
<div className="space-y-2">
<div className="flex gap-3 items-start">
<span className="text-gray-400 shrink-0 w-14">IP</span>
<span className="font-mono font-medium">{item.ip}</span>
</div>
{item.countryName && (
<div className="flex gap-3 items-start">
<span className="text-gray-400 shrink-0 w-14">국가</span>
<span>{item.countryName}</span>
</div>
)}
{item.hostname && (
<div className="flex gap-3 items-start">
<span className="text-gray-400 shrink-0 w-14">호스트</span>
<span className="font-mono text-xs break-all leading-relaxed">{item.hostname}</span>
</div>
)}
{item.mailFrom && (
<div className="flex gap-3 items-start">
<span className="text-gray-400 shrink-0 w-14">발신</span>
<span className="break-all text-blue-300">{item.mailFrom}</span>
</div>
)}
{item.rcptTo && (
<div className="flex gap-3 items-start">
<span className="text-gray-400 shrink-0 w-14">수신</span>
<span className="break-all text-green-300">{item.rcptTo}</span>
</div>
)}
<div className="flex gap-3 items-start pt-2 mt-2 border-t border-gray-600">
<span className="text-gray-400 shrink-0 w-14">시간</span>
<span className="text-yellow-200">{formatKoreanDate(item.connectedAt)}</span>
</div>
</div>
{/* 화살표 */}
<div className="absolute -top-2 left-5 w-4 h-4 bg-gray-800 rotate-45"></div>
</div>
)}
</div>
);
};
/**
* 통계 카드 컴포넌트 (파스텔톤)
*/
const StatCard = ({ title, value, icon: Icon, bgColor, iconBg }) => (
<div className={`relative overflow-hidden p-5 rounded-2xl shadow-sm border border-gray-100 h-24 ${bgColor}`}>
<div className="flex items-center justify-between h-full">
<div className="min-w-0">
<p className="text-gray-600 text-sm font-medium mb-1">{title}</p>
<h3 className="text-2xl font-bold text-gray-800 truncate whitespace-nowrap">{value}</h3>
</div>
<div className={`p-3 rounded-xl flex-shrink-0 ${iconBg}`}>
<Icon size={24} className="text-white" />
</div>
</div>
</div>
);
/**
* 기간 선택 - cursor-pointer 명시
*/
const PeriodTabs = ({ selected, onChange, disabled }) => {
const periods = [
{ value: '1d', label: '1일' },
{ value: '7d', label: '7일' },
{ value: '30d', label: '30일' },
{ value: 'all', label: '전체' },
];
return (
<div className="flex gap-0.5 bg-gray-100 p-0.5 rounded-md shrink-0">
{periods.map(p => (
<button
key={p.value}
onClick={() => !disabled && onChange(p.value)}
disabled={disabled}
type="button"
className={`px-2 py-1 text-[12px] font-medium rounded transition-all cursor-pointer select-none ${
selected === p.value
? 'bg-white text-gray-800 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
} ${disabled ? 'opacity-50' : ''}`}
>
{p.label}
</button>
))}
</div>
);
};
/**
* 커스텀 툴팁
*/
const CustomTooltip = ({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900/95 backdrop-blur-md px-4 py-3 rounded-xl shadow-2xl border border-gray-700">
<p className="text-gray-300 text-xs mb-2 font-medium">{label}</p>
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }}></div>
<span className="text-white text-sm font-semibold">{entry.name}: {entry.value}</span>
</div>
))}
</div>
);
}
return null;
};
const AdminDashboard = () => {
const { fetchStats } = useMail();
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [periodLoading, setPeriodLoading] = useState(false);
const [period, setPeriod] = useState('all');
// IP
const [remoteIps, setRemoteIps] = useState([]);
const [remoteIpsPage, setRemoteIpsPage] = useState(1);
const [remoteIpsHasMore, setRemoteIpsHasMore] = useState(true);
const [remoteIpsLoading, setRemoteIpsLoading] = useState(false);
const remoteIpsRef = useRef(null);
const remoteIpsLoadingRef = useRef(false);
//
const [recentLogs, setRecentLogs] = useState([]);
const [recentLogsPage, setRecentLogsPage] = useState(1);
const [recentLogsHasMore, setRecentLogsHasMore] = useState(true);
const [recentLogsLoading, setRecentLogsLoading] = useState(false);
const recentLogsRef = useRef(null);
const recentLogsLoadingRef = useRef(false);
//
const loadStats = useCallback(async (selectedPeriod = period, isInitial = false) => {
if (isInitial) setLoading(true);
else setPeriodLoading(true);
try {
const token = localStorage.getItem('email_token');
const res = await fetch(`/api/admin/stats?period=${selectedPeriod}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const data = await res.json();
setStats(data);
} catch (e) {
console.error('통계 로드 오류:', e);
} finally {
setLoading(false);
setPeriodLoading(false);
}
}, [period]);
// IP
const loadRemoteIps = useCallback(async (page = 1, reset = false, currentPeriod = period) => {
if (remoteIpsLoadingRef.current) return;
remoteIpsLoadingRef.current = true;
setRemoteIpsLoading(true);
try {
const token = localStorage.getItem('email_token');
const res = await fetch(`/api/admin/remote-ips?page=${page}&limit=10&period=${currentPeriod}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const { data, pagination } = await res.json();
if (reset) {
setRemoteIps(data || []);
} else {
setRemoteIps(prev => [...prev, ...(data || [])]);
}
setRemoteIpsPage(page);
setRemoteIpsHasMore(pagination?.hasMore ?? false);
} catch (e) {
console.error('접속 IP 로드 오류:', e);
} finally {
remoteIpsLoadingRef.current = false;
setRemoteIpsLoading(false);
}
}, [period]);
//
const loadRecentLogs = useCallback(async (page = 1, reset = false) => {
if (recentLogsLoadingRef.current) return;
recentLogsLoadingRef.current = true;
setRecentLogsLoading(true);
try {
const token = localStorage.getItem('email_token');
const res = await fetch(`/api/admin/recent-logs?page=${page}&limit=10`, {
headers: { 'Authorization': `Bearer ${token}` }
});
const { data, pagination } = await res.json();
if (reset) {
setRecentLogs(data || []);
} else {
setRecentLogs(prev => [...prev, ...(data || [])]);
}
setRecentLogsPage(page);
setRecentLogsHasMore(pagination?.hasMore ?? false);
} catch (e) {
console.error('최근 활동 로드 오류:', e);
} finally {
recentLogsLoadingRef.current = false;
setRecentLogsLoading(false);
}
}, []);
//
useEffect(() => {
loadStats('all', true);
loadRemoteIps(1, true, 'all');
loadRecentLogs(1, true);
}, []);
// IP
const handlePeriodChange = (newPeriod) => {
setPeriod(newPeriod);
loadStats(newPeriod, false);
// IP
setRemoteIps([]);
setRemoteIpsPage(1);
setRemoteIpsHasMore(true);
remoteIpsLoadingRef.current = false;
loadRemoteIps(1, true, newPeriod);
};
// IP
const handleRemoteIpsScroll = useCallback((e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollHeight - scrollTop <= clientHeight + 50 && !remoteIpsLoadingRef.current) {
// hasMore ref
setRemoteIpsPage(prevPage => {
setRemoteIpsHasMore(hasMore => {
if (hasMore) {
loadRemoteIps(prevPage + 1, false, period);
}
return hasMore;
});
return prevPage;
});
}
}, [period, loadRemoteIps]);
//
const handleRecentLogsScroll = useCallback((e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollHeight - scrollTop <= clientHeight + 50 && !recentLogsLoadingRef.current) {
setRecentLogsPage(prevPage => {
setRecentLogsHasMore(hasMore => {
if (hasMore) {
loadRecentLogs(prevPage + 1, false);
}
return hasMore;
});
return prevPage;
});
}
}, [loadRecentLogs]);
if (loading || !stats) {
return (
<div className="flex items-center justify-center h-full bg-gray-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-500 font-medium">통계 로딩 ...</span>
</div>
</div>
);
}
const storagePercent = stats.storageLimit
? ((parseFloat(stats.storageUsed) / stats.storageLimit) * 100).toFixed(1)
: 0;
return (
<div className="p-8 bg-gray-50 min-h-full overflow-y-auto overflow-x-hidden">
{/* 헤더 */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-800">메일서버 대시보드</h1>
<p className="text-gray-500 text-sm mt-1">실시간 서버 상태 통계</p>
</div>
</div>
<div className="grid grid-cols-4 gap-5 mb-8">
<StatCard
title="전체 사용자"
value={stats.userCount}
icon={Users}
bgColor="bg-emerald-50"
iconBg="bg-emerald-400"
/>
<StatCard
title="전체 발송"
value={stats.totalSent || 0}
icon={Send}
bgColor="bg-blue-50"
iconBg="bg-blue-400"
/>
<StatCard
title="전체 수신"
value={stats.totalReceived || 0}
icon={Inbox}
bgColor="bg-violet-50"
iconBg="bg-violet-400"
/>
<StatCard
title="전체 스팸 차단"
value={stats.totalSpam || 0}
icon={ShieldAlert}
bgColor="bg-red-50"
iconBg="bg-red-400"
/>
</div>
{/* 메인 그래프 + 시스템 상태 */}
<div className="grid grid-cols-3 gap-6 mb-6">
{/* 주간 트래픽 그래프 */}
<div className="col-span-2 bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold text-gray-800">주간 트래픽</h3>
<p className="text-sm text-gray-500">최근 7일간 메일 발송/수신 현황</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-400"></div>
<span className="text-gray-600">발송</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-emerald-400"></div>
<span className="text-gray-600">수신</span>
</div>
</div>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.chartData}>
<defs>
<linearGradient id="gradientSent" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity={0.4}/>
<stop offset="100%" stopColor="#60a5fa" stopOpacity={0.05}/>
</linearGradient>
<linearGradient id="gradientReceived" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#34d399" stopOpacity={0.4}/>
<stop offset="100%" stopColor="#34d399" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e5e7eb" />
<XAxis dataKey="name" stroke="#9ca3af" fontSize={12} tickLine={false} axisLine={false} />
<YAxis stroke="#9ca3af" fontSize={12} tickLine={false} axisLine={false} width={30} />
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="sent"
stroke="#60a5fa"
strokeWidth={2.5}
fill="url(#gradientSent)"
name="발송"
dot={{ fill: '#60a5fa', strokeWidth: 0, r: 3 }}
activeDot={{ r: 5, fill: '#60a5fa', stroke: '#fff', strokeWidth: 2 }}
/>
<Area
type="monotone"
dataKey="received"
stroke="#34d399"
strokeWidth={2.5}
fill="url(#gradientReceived)"
name="수신"
dot={{ fill: '#34d399', strokeWidth: 0, r: 3 }}
activeDot={{ r: 5, fill: '#34d399', stroke: '#fff', strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* 시스템 상태 */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100">
<h3 className="text-lg font-bold text-gray-800 mb-5">시스템 상태</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center space-x-3">
<ShieldAlert className="text-gray-600" size={20} />
<span className="text-gray-700 font-medium">스팸 필터링 서비스</span>
</div>
<span className={`px-3 py-1.5 text-xs font-bold rounded-full ${
stats.rspamdStatus === 'Running'
? 'bg-emerald-100 text-emerald-700'
: 'bg-red-100 text-red-700'
}`}>
{stats.rspamdStatus === 'Running' ? '정상' : '중지됨'}
</span>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-xl">
<div className="flex items-center space-x-3">
<Database className="text-gray-600" size={20} />
<span className="text-gray-700 font-medium">데이터베이스</span>
</div>
<span className={`px-3 py-1.5 text-xs font-bold rounded-full ${
stats.dbStatus === 'Connected'
? 'bg-blue-100 text-blue-700'
: 'bg-red-100 text-red-700'
}`}>
{stats.dbStatus === 'Connected' ? '연결됨' : '연결 끊김'}
</span>
</div>
</div>
{/* 스토리지 */}
<div className="mt-6 pt-5 border-t border-gray-100">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<HardDrive className="text-gray-600" size={20} />
<span className="text-gray-700 font-medium">스토리지</span>
</div>
<span className="text-sm font-semibold text-gray-600">{stats.storageUsed}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className={`h-2.5 rounded-full ${
storagePercent >= 90 ? 'bg-red-500' :
storagePercent >= 70 ? 'bg-amber-500' : 'bg-blue-500'
}`}
style={{ width: `${Math.min(storagePercent, 100)}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-2 text-right">
{storagePercent}% 사용 (최대 {stats.storageLimit} MB)
</p>
</div>
</div>
</div>
{/* 하단 섹션 */}
<div className="grid grid-cols-3 gap-6">
{/* Top Users - 기간 선택 포함 */}
<div className={`bg-white p-6 rounded-2xl shadow-sm border border-gray-100 transition-opacity overflow-hidden ${periodLoading ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between gap-2 mb-5 flex-nowrap">
<h3 className="text-lg font-bold text-gray-800">사용자별 통계</h3>
<PeriodTabs selected={period} onChange={handlePeriodChange} disabled={periodLoading} />
</div>
<div className="space-y-3 max-h-64 overflow-y-auto">
{stats.topUsers && stats.topUsers.length > 0 ? (
stats.topUsers.map((user, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-white text-xs font-bold">
{index + 1}
</div>
<span className="text-sm font-medium text-gray-700 truncate max-w-[100px]">
{user.email.split('@')[0]}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-red-500" title="발송">
<ArrowUp size={14} />
<span>{user.sent}</span>
</div>
<div className="flex items-center gap-1 text-emerald-500" title="수신">
<ArrowDown size={14} />
<span>{user.received}</span>
</div>
</div>
</div>
))
) : (
<div className="text-center py-8 text-gray-400">
<Users size={32} className="mx-auto mb-2" />
<p className="text-sm">데이터 없음</p>
</div>
)}
</div>
</div>
{/* 접속 IP - 국가 정보 및 호스트명 포함 */}
<div className={`bg-white p-6 rounded-2xl shadow-sm border border-gray-100 transition-opacity overflow-hidden ${periodLoading ? 'opacity-60' : ''}`}>
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-bold text-gray-800">접속 IP</h3>
<Globe size={18} className="text-blue-500" />
</div>
<div className="space-y-2 max-h-64 overflow-y-auto overflow-x-hidden" ref={remoteIpsRef} onScroll={handleRemoteIpsScroll}>
{remoteIps && remoteIps.length > 0 ? (
remoteIps.map((item, index) => (
<IpTooltip key={index} item={item}>
<div className="flex items-center gap-3 p-2 bg-gray-50 rounded-lg hover:bg-blue-50 transition-colors cursor-default mr-2">
{/* 국기 */}
<span className="shrink-0 w-6 h-4 flex items-center justify-center">
{(!item.country || item.country === 'LOCAL') ? (
<span className="text-base">🏠</span>
) : (
<img
src={`https://flagcdn.com/w40/${item.country.toLowerCase()}.png`}
srcSet={`https://flagcdn.com/w80/${item.country.toLowerCase()}.png 2x`}
width="20"
alt={item.country}
className="rounded-sm object-cover"
/>
)}
</span>
{/* IP 주소 */}
<span className="flex-1 text-sm font-mono text-gray-700 truncate">
{item.ip}
</span>
{/* 시간 */}
<span className="text-xs text-gray-400 shrink-0">
{formatRelativeTime(item.connectedAt)}
</span>
</div>
</IpTooltip>
))
) : (
!remoteIpsLoading && (
<div className="text-center py-8 text-gray-400">
<Globe size={32} className="mx-auto mb-2" />
<p className="text-sm">접속 기록 없음</p>
</div>
)
)}
{remoteIpsLoading && (
<div className="text-center py-3">
<div className="w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto"></div>
</div>
)}
</div>
</div>
{/* 최근 활동 */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex items-center justify-between mb-5">
<h3 className="text-lg font-bold text-gray-800">최근 활동</h3>
<Clock size={18} className="text-gray-400" />
</div>
<div className="space-y-3 max-h-64 overflow-y-auto" ref={recentLogsRef} onScroll={handleRecentLogsScroll}>
{recentLogs && recentLogs.length > 0 ? (
recentLogs.map((log, index) => {
let Icon = Server;
let iconBg = "bg-gray-400";
if (log.type === 'INBOX') { Icon = Inbox; iconBg = "bg-violet-400"; }
else if (log.type === 'SENT') { Icon = Send; iconBg = "bg-blue-400"; }
else if (log.type === 'USER') { Icon = Users; iconBg = "bg-emerald-400"; }
return (
<div key={index} className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-lg transition-colors mr-2">
<div className={`p-2 rounded-lg ${iconBg}`}>
<Icon size={14} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">{log.message}</p>
<p className="text-xs text-gray-400">
{new Date(log.date).toLocaleString('ko-KR', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
})}
</p>
</div>
</div>
);
})
) : (
!recentLogsLoading && (
<div className="text-center py-6 text-gray-400">
<Clock size={28} className="mx-auto mb-2" />
<p className="text-sm">활동 없음</p>
</div>
)
)}
{recentLogsLoading && (
<div className="text-center py-4">
<div className="w-5 h-5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto"></div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default AdminDashboard;

View file

@ -0,0 +1,143 @@
/**
* 관리자 레이아웃 (사이드바)
* 네비게이션 메뉴, 로그아웃 기능
*/
import React, { useState } from 'react';
import { LayoutDashboard, Users, Settings, LogOut, ArrowLeft, AlertCircle, Shield } from 'lucide-react';
import { useMail } from '../../context/MailContext';
import { Dialog, Typography, Button, Fade } from '@mui/material';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Fade ref={ref} {...props} />;
});
const AdminLayout = ({ currentView, onViewChange, onBack }) => {
const { logout } = useMail();
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
const menuItems = [
{ id: 'dashboard', label: '대시보드', icon: LayoutDashboard },
{ id: 'users', label: '사용자 관리', icon: Users },
{ id: 'settings', label: '시스템 설정', icon: Settings },
];
const handleLogoutClick = () => {
setLogoutDialogOpen(true);
};
const confirmLogout = () => {
setLogoutDialogOpen(false);
logout();
};
return (
<div className="flex flex-col h-full bg-gradient-to-b from-slate-900 to-slate-800">
{/* 관리자 헤더 영역 */}
<div className="px-6 py-6">
<div className="flex items-center gap-3">
<div className="w-11 h-11 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-500/30">
<Shield className="text-white" size={22} />
</div>
<div>
<h1 className="text-white font-bold text-lg">Admin</h1>
<p className="text-slate-400 text-xs">관리자 패널</p>
</div>
</div>
</div>
{/* 뒤로가기 버튼 */}
<div className="px-4 mb-4">
<button
onClick={onBack}
className="w-full flex items-center gap-3 px-4 py-3 text-slate-300 hover:text-white hover:bg-white/5 rounded-xl transition-all group"
>
<ArrowLeft size={18} className="group-hover:-translate-x-1 transition-transform" />
<span className="text-sm font-medium">메일함으로 돌아가기</span>
</button>
</div>
{/* 구분선 */}
<div className="mx-6 mb-4 border-t border-white/10"></div>
{/* 섹션 라벨 */}
<div className="px-6 mb-2">
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">메뉴</span>
</div>
{/* 네비게이션 메뉴 */}
<nav className="flex-1 px-4">
<div className="space-y-1">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
isActive
? 'bg-gradient-to-r from-indigo-500 to-indigo-600 text-white shadow-lg shadow-indigo-500/30'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<Icon size={20} className={isActive ? 'text-white' : ''} />
<span className="text-sm font-medium">{item.label}</span>
</button>
);
})}
</div>
</nav>
{/* 로그아웃 버튼 */}
<div className="px-4 py-6 border-t border-white/10">
<button
onClick={handleLogoutClick}
className="w-full flex items-center gap-3 px-4 py-3 text-slate-400 hover:text-rose-400 hover:bg-rose-500/5 rounded-xl transition-all"
>
<LogOut size={20} />
<span className="text-sm font-medium">로그아웃</span>
</button>
</div>
{/* 로그아웃 확인 다이얼로그 */}
<Dialog
open={logoutDialogOpen}
onClose={() => setLogoutDialogOpen(false)}
TransitionComponent={Transition}
PaperProps={{ sx: { borderRadius: '20px', padding: '8px' } }}
>
<div className="p-6 text-center">
<div className="mx-auto bg-red-100 w-16 h-16 rounded-2xl flex items-center justify-center mb-4">
<AlertCircle className="h-8 w-8 text-red-500" />
</div>
<Typography variant="h6" fontWeight="bold" gutterBottom>
로그아웃 하시겠습니까?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
현재 세션이 종료되고 로그인 화면으로 이동합니다.
</Typography>
<div className="flex gap-3 justify-center">
<Button
onClick={() => setLogoutDialogOpen(false)}
variant="outlined"
sx={{ color: '#6b7280', borderColor: '#d1d5db', borderRadius: '12px', px: 4, textTransform: 'none', fontWeight: 600 }}
>
취소
</Button>
<Button
onClick={confirmLogout}
variant="contained"
color="error"
sx={{ borderRadius: '12px', px: 4, boxShadow: 'none', textTransform: 'none', fontWeight: 600 }}
>
로그아웃
</Button>
</div>
</div>
</Dialog>
</div>
);
};
export default AdminLayout;

View file

@ -0,0 +1,77 @@
import React, { useEffect } from 'react';
import { Routes, Route, useNavigate, useLocation, Navigate } from 'react-router-dom';
import AdminLayout from './AdminLayout';
import AdminDashboard from './AdminDashboard';
import UserManagement from './UserManagement';
import AdminSettings from './AdminSettings';
import { useMail } from '../../context/MailContext';
const AdminPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, initialLoading } = useMail();
// user ( , URL , )
useEffect(() => {
//
if (initialLoading) return;
// user
if (user && !user.isAdmin) {
sessionStorage.setItem('admin_denied_message', '권한이 없습니다. 메인 화면으로 이동합니다.');
window.location.href = '/mail/inbox';
}
// user ( )
if (!initialLoading && !user) {
window.location.href = '/';
}
}, [user, initialLoading]);
//
if (initialLoading) {
return null;
}
//
if (!user || !user.isAdmin) {
return null;
}
// URL
const getCurrentView = () => {
const path = location.pathname;
if (path.includes('/users')) return 'users';
if (path.includes('/settings')) return 'settings';
return 'dashboard';
};
const handleViewChange = (viewId) => {
navigate(`/admin/${viewId}`);
};
return (
<div className="flex h-screen bg-white overflow-hidden font-sans text-slate-800 min-w-[1400px]">
{/* 고정 사이드바 래퍼 */}
<div className="w-80 flex-shrink-0 h-full">
<AdminLayout
currentView={getCurrentView()}
onViewChange={handleViewChange}
onBack={() => navigate('/')}
/>
</div>
{/* 메인 콘텐츠 영역 */}
<div className="flex-1 bg-[#f8f9fa] overflow-auto h-full">
<Routes>
<Route path="dashboard" element={<AdminDashboard />} />
<Route path="users" element={<UserManagement />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="*" element={<Navigate to="dashboard" replace />} />
</Routes>
</div>
</div>
);
};
export default AdminPage;

View file

@ -0,0 +1,545 @@
/**
* 시스템 설정 컴포넌트
* Resend 이메일 연동, 스토리지 설정, 세션 설정
* 섹션별 독립 저장 버튼
*/
import React, { useEffect, useState } from 'react';
import { useMail } from '../../context/MailContext';
import { Save, RefreshCw, HardDrive, Mail, Key, CheckCircle, XCircle, AlertTriangle, Shield, Eye, EyeOff, Clock, Send, Sparkles, ChevronDown, Check } from 'lucide-react';
import { Alert, Snackbar } from '@mui/material';
const AdminSettings = () => {
const { fetchEmailConfig, updateEmailConfig, testEmailConnection } = useMail();
//
const [emailConfig, setEmailConfig] = useState({
resend_api_key: '',
mail_from: '',
});
//
const [storageConfig, setStorageConfig] = useState({
user_storage_quota: '50'
});
//
const [systemConfig, setSystemConfig] = useState({
session_expire_hours: '24'
});
// Gemini
const [geminiConfig, setGeminiConfig] = useState({
gemini_api_key: '',
gemini_model: 'gemini-2.0-flash'
});
const [geminiModels, setGeminiModels] = useState([]);
//
const [emailLoading, setEmailLoading] = useState(false);
const [storageLoading, setStorageLoading] = useState(false);
const [systemLoading, setSystemLoading] = useState(false);
const [geminiLoading, setGeminiLoading] = useState(false);
const [testLoading, setTestLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [message, setMessage] = useState({ type: '', text: '', open: false });
const [testStatus, setTestStatus] = useState(null);
// API /
const [showApiKey, setShowApiKey] = useState(false);
const [showGeminiApiKey, setShowGeminiApiKey] = useState(false);
// Gemini
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
useEffect(() => {
loadAllConfigs();
}, []);
const loadAllConfigs = async () => {
setPageLoading(true);
try {
const data = await fetchEmailConfig();
setEmailConfig({
resend_api_key: data.resend_api_key || '',
mail_from: data.mail_from || '',
});
setStorageConfig({
user_storage_quota: data.user_storage_quota ? (parseInt(data.user_storage_quota) / 1024).toString() : '50'
});
setSystemConfig({
session_expire_hours: data.session_expire_hours || '24'
});
// Gemini
try {
const token = localStorage.getItem('email_token');
const geminiRes = await fetch('/api/admin/config/gemini', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (geminiRes.ok) {
const geminiData = await geminiRes.json();
setGeminiConfig({
gemini_api_key: geminiData.gemini_api_key || '',
gemini_model: geminiData.gemini_model || 'gemini-2.0-flash'
});
setGeminiModels(geminiData.models || []);
}
} catch (e) {
console.error('Gemini 설정 로드 실패:', e);
}
} catch (e) {
console.error(e);
} finally {
setPageLoading(false);
}
};
const checkAdminPermission = async () => {
const token = localStorage.getItem('email_token');
if (!token) return false;
try {
const res = await fetch('/api/verify', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
if (data.valid && data.user && !data.user.isAdmin) {
sessionStorage.setItem('admin_denied_message', '권한이 없습니다. 메인 화면으로 이동합니다.');
window.location.href = '/mail/inbox';
return false;
}
}
return true;
} catch {
return true;
}
};
//
const handleSaveEmail = async () => {
if (!await checkAdminPermission()) return;
setEmailLoading(true);
try {
await updateEmailConfig(emailConfig);
setMessage({ type: 'success', text: '이메일 설정이 저장되었습니다.', open: true });
} catch (err) {
setMessage({ type: 'error', text: '이메일 설정 저장 실패: ' + err.message, open: true });
} finally {
setEmailLoading(false);
}
};
//
const handleSaveStorage = async () => {
if (!await checkAdminPermission()) return;
setStorageLoading(true);
try {
await updateEmailConfig({
user_storage_quota: (parseFloat(storageConfig.user_storage_quota) * 1024).toString()
});
setMessage({ type: 'success', text: '스토리지 설정이 저장되었습니다.', open: true });
} catch (err) {
setMessage({ type: 'error', text: '스토리지 설정 저장 실패: ' + err.message, open: true });
} finally {
setStorageLoading(false);
}
};
//
const handleSaveSystem = async () => {
if (!await checkAdminPermission()) return;
setSystemLoading(true);
try {
await updateEmailConfig(systemConfig);
setMessage({ type: 'success', text: '세션 설정이 저장되었습니다.', open: true });
} catch (err) {
setMessage({ type: 'error', text: '세션 설정 저장 실패: ' + err.message, open: true });
} finally {
setSystemLoading(false);
}
};
// Gemini
const handleSaveGemini = async () => {
if (!await checkAdminPermission()) return;
setGeminiLoading(true);
try {
const token = localStorage.getItem('email_token');
const res = await fetch('/api/admin/config/gemini', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(geminiConfig)
});
if (!res.ok) throw new Error('저장 실패');
setMessage({ type: 'success', text: 'Gemini 설정이 저장되었습니다.', open: true });
} catch (err) {
setMessage({ type: 'error', text: 'Gemini 설정 저장 실패: ' + err.message, open: true });
} finally {
setGeminiLoading(false);
}
};
//
const handleTest = async () => {
if (!await checkAdminPermission()) return;
if (!emailConfig.resend_api_key) {
setMessage({ type: 'error', text: 'API 키를 입력해주세요.', open: true });
return;
}
setTestLoading(true);
setTestStatus(null);
try {
await testEmailConnection({
to: emailConfig.mail_from || 'admin@caadiq.co.kr',
resend_api_key: emailConfig.resend_api_key,
mail_from: emailConfig.mail_from
});
setTestStatus('success');
setMessage({ type: 'success', text: '연동 테스트 성공! 이메일이 발송되었습니다.', open: true });
} catch (err) {
setTestStatus('error');
setMessage({ type: 'error', text: '연동 테스트 실패: ' + err.message, open: true });
} finally {
setTestLoading(false);
}
};
if (pageLoading) {
return (
<div className="flex items-center justify-center h-full bg-gray-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-500 font-medium">설정 로딩 ...</span>
</div>
</div>
);
}
return (
<div className="p-8 bg-gray-50 min-h-full">
{/* 헤더 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-800">시스템 설정</h1>
<p className="text-gray-500 text-sm mt-1">메일 서버의 연동 스토리지 환경을 설정합니다</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 왼쪽 컬럼 */}
<div className="space-y-6">
{/* Resend 이메일 설정 */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-indigo-100 rounded-xl">
<Send className="text-indigo-500" size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">Resend 연동</h2>
<p className="text-sm text-gray-500">이메일 발송을 위한 Resend API 설정</p>
</div>
</div>
<div className="space-y-5">
{/* Mail From */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<div className="flex items-center gap-2">
<Mail size={14} className="text-gray-400" />
발신자 이메일
</div>
</label>
<input
type="email"
value={emailConfig.mail_from}
onChange={(e) => setEmailConfig({ ...emailConfig, mail_from: e.target.value })}
placeholder="noreply@caadiq.co.kr"
className="w-full p-3.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-100 focus:border-indigo-300 outline-none transition-all"
/>
<p className="text-xs text-gray-400 mt-1.5 flex items-center gap-1">
<AlertTriangle size={12} />
Resend에서 인증된 도메인의 이메일이어야 합니다
</p>
</div>
{/* API Key */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<div className="flex items-center gap-2">
<Key size={14} className="text-gray-400" />
Resend API Key
</div>
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={emailConfig.resend_api_key}
onChange={(e) => setEmailConfig({ ...emailConfig, resend_api_key: e.target.value })}
placeholder="re_xxxxx..."
autoComplete="off"
className="w-full p-3.5 pr-12 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-100 focus:border-indigo-300 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
>
{showApiKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="text-xs text-gray-400 mt-1.5">
Resend 대시보드에서 API 키를 발급받으세요
</p>
</div>
</div>
{/* 이메일 액션 버튼들 */}
<div className="mt-6 pt-6 border-t border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-3">
{testStatus === 'success' && (
<div className="flex items-center gap-2 text-emerald-600 bg-emerald-50 px-3 py-1.5 rounded-lg">
<CheckCircle size={16} />
<span className="text-sm font-medium">연동 성공</span>
</div>
)}
{testStatus === 'error' && (
<div className="flex items-center gap-2 text-red-600 bg-red-50 px-3 py-1.5 rounded-lg">
<XCircle size={16} />
<span className="text-sm font-medium">연동 실패</span>
</div>
)}
<button
type="button"
onClick={handleTest}
disabled={testLoading}
className="px-4 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-xl hover:bg-indigo-100 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${testLoading ? 'animate-spin' : ''}`} />
테스트
</button>
</div>
<button
type="button"
onClick={handleSaveEmail}
disabled={emailLoading}
className="px-5 py-2.5 text-sm font-semibold text-white bg-indigo-500 rounded-xl hover:bg-indigo-600 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{emailLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
저장
</button>
</div>
</div>
{/* Gemini AI 설정 */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-amber-100 rounded-xl">
<Sparkles className="text-amber-500" size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">Gemini AI 번역</h2>
<p className="text-sm text-gray-500">이메일 번역을 위한 Google Gemini API 설정</p>
</div>
</div>
<div className="space-y-5">
{/* API Key */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<div className="flex items-center gap-2">
<Key size={14} className="text-gray-400" />
Gemini API Key
</div>
</label>
<div className="relative">
<input
type={showGeminiApiKey ? "text" : "password"}
value={geminiConfig.gemini_api_key}
onChange={(e) => setGeminiConfig({ ...geminiConfig, gemini_api_key: e.target.value })}
placeholder="AI..."
autoComplete="off"
className="w-full p-3.5 pr-12 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-amber-100 focus:border-amber-300 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => setShowGeminiApiKey(!showGeminiApiKey)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
>
{showGeminiApiKey ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<p className="text-xs text-gray-400 mt-1.5">
Google AI Studio에서 API 키를 발급받으세요
</p>
</div>
{/* 모델 선택 */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
모델 선택
</label>
<div className="relative">
<button
type="button"
onClick={() => setModelDropdownOpen(!modelDropdownOpen)}
className="w-full text-left border border-gray-200 rounded-xl px-3.5 py-3 text-sm text-gray-700 bg-gray-50 flex items-center justify-between focus:border-amber-300 focus:ring-2 focus:ring-amber-100 transition-all"
>
<span>{geminiModels.find(m => m.id === geminiConfig.gemini_model)?.name || 'Gemini 2.0 Flash'}</span>
<ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${modelDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{modelDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-50 py-1 animate-fade-in-down max-h-64 overflow-y-auto">
{geminiModels.map((model) => (
<button
key={model.id}
type="button"
onClick={() => {
setGeminiConfig({ ...geminiConfig, gemini_model: model.id });
setModelDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center justify-between gap-3 ${geminiConfig.gemini_model === model.id ? 'bg-amber-50 text-amber-600 font-medium' : 'text-gray-700'}`}
>
<span>{model.name}</span>
{geminiConfig.gemini_model === model.id && <Check className="h-4 w-4" />}
</button>
))}
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-1.5">무료 티어에서 사용 가능한 모델입니다</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-gray-100 flex justify-end">
<button
type="button"
onClick={handleSaveGemini}
disabled={geminiLoading}
className="px-5 py-2.5 text-sm font-semibold text-white bg-amber-500 rounded-xl hover:bg-amber-600 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{geminiLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
저장
</button>
</div>
</div>
</div>
{/* 오른쪽 컬럼 */}
<div className="space-y-6">
{/* 스토리지 설정 */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-emerald-100 rounded-xl">
<HardDrive className="text-emerald-500" size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">스토리지 설정</h2>
<p className="text-sm text-gray-500">사용자별 저장 공간 할당량</p>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
사용자 기본 할당량
</label>
<div className="flex items-center gap-3">
<input
type="number"
value={storageConfig.user_storage_quota}
onChange={(e) => setStorageConfig({ user_storage_quota: e.target.value })}
placeholder="50"
min="1"
className="w-32 p-3.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-emerald-100 focus:border-emerald-300 outline-none transition-all text-center font-semibold text-lg"
/>
<span className="text-gray-600 font-medium">GB</span>
</div>
<p className="text-xs text-gray-400 mt-2">모든 사용자에게 적용되는 기본 스토리지 용량입니다</p>
</div>
<div className="mt-6 pt-6 border-t border-gray-100 flex justify-end">
<button
type="button"
onClick={handleSaveStorage}
disabled={storageLoading}
className="px-5 py-2.5 text-sm font-semibold text-white bg-emerald-500 rounded-xl hover:bg-emerald-600 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{storageLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
저장
</button>
</div>
</div>
{/* 세션 설정 */}
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-violet-100 rounded-xl">
<Shield className="text-violet-500" size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-gray-800">세션 설정</h2>
<p className="text-sm text-gray-500">로그인 유지 시간</p>
</div>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-400" />
세션 만료 시간
</div>
</label>
<div className="flex items-center gap-3">
<input
type="number"
value={systemConfig.session_expire_hours}
onChange={(e) => setSystemConfig({ ...systemConfig, session_expire_hours: e.target.value })}
placeholder="24"
min="1"
max="8760"
className="w-32 p-3.5 bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-violet-100 focus:border-violet-300 outline-none transition-all text-center font-semibold text-lg"
/>
<span className="text-gray-600 font-medium">시간</span>
</div>
<p className="text-xs text-gray-400 mt-1.5">로그인 자동 로그아웃까지의 시간 (1~8760시간, 최대 1)</p>
</div>
<div className="mt-6 pt-6 border-t border-gray-100 flex justify-end">
<button
type="button"
onClick={handleSaveSystem}
disabled={systemLoading}
className="px-5 py-2.5 text-sm font-semibold text-white bg-violet-500 rounded-xl hover:bg-violet-600 transition-colors flex items-center gap-2 disabled:opacity-50"
>
{systemLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
저장
</button>
</div>
</div>
</div>
</div>
<Snackbar
open={message.open}
autoHideDuration={4000}
onClose={() => setMessage({ ...message, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={message.type}
variant="filled"
onClose={() => setMessage({ ...message, open: false })}
sx={{ borderRadius: '12px' }}
>
{message.text}
</Alert>
</Snackbar>
</div>
);
};
export default AdminSettings;

View file

@ -0,0 +1,530 @@
/**
* 사용자 관리 컴포넌트
* 사용자 목록 조회, 추가, 수정, 삭제 기능
*/
import React, { useEffect, useState } from 'react';
import { useMail } from '../../context/MailContext';
import { Trash2, UserPlus, Shield, User, Pencil, Check, Search, Users } from 'lucide-react';
import { Button, IconButton, Dialog, DialogTitle, Fade, TextField, InputAdornment } from '@mui/material';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import EmailIcon from '@mui/icons-material/Email';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import BadgeIcon from '@mui/icons-material/Badge';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Fade ref={ref} {...props} />;
});
const UserManagement = () => {
const { fetchUsers, addUser, deleteUser, updateUser, user: currentUser } = useMail();
const [users, setUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newUser, setNewUser] = useState({ email: '', password: '', name: '', isAdmin: false });
const [editingUser, setEditingUser] = useState(null);
const [userToDelete, setUserToDelete] = useState(null);
const [errors, setErrors] = useState({});
const [globalError, setGlobalError] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
setLoading(true);
try {
const data = await fetchUsers();
setUsers(data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
const validate = (user, isEdit = false) => {
const newErrors = {};
if (!user.email) newErrors.email = '아이디를 입력해주세요.';
if (!user.name) newErrors.name = '이름을 입력해주세요.';
if (!isEdit && !user.password) newErrors.password = '비밀번호를 입력해주세요.';
if (user.password && user.password.length < 4) newErrors.password = '비밀번호는 4자 이상이어야 합니다.';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* 권한 확인 - 403이면 메인으로 리다이렉트
*/
const checkAdminPermission = async () => {
const token = localStorage.getItem('email_token');
if (!token) return false;
try {
const res = await fetch('/api/verify', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
if (data.valid && data.user && !data.user.isAdmin) {
// -
sessionStorage.setItem('admin_denied_message', '권한이 없습니다. 메인 화면으로 이동합니다.');
window.location.href = '/mail/inbox';
return false;
}
}
return true;
} catch {
return true; //
}
};
// ( )
const openAddDialog = async () => {
if (!await checkAdminPermission()) return;
setErrors({});
setGlobalError('');
setAddDialogOpen(true);
};
const handleAdd = async () => {
//
if (!await checkAdminPermission()) return;
setGlobalError('');
if (!validate(newUser)) return;
try {
const fullEmail = `${newUser.email}@caadiq.co.kr`;
await addUser({ ...newUser, email: fullEmail });
setAddDialogOpen(false);
setNewUser({ email: '', password: '', name: '', isAdmin: false });
loadUsers();
} catch (e) {
setGlobalError('생성 실패: ' + (e.message || '알 수 없는 오류'));
}
};
// ( )
const startEdit = async (user) => {
if (!await checkAdminPermission()) return;
const [idPart] = user.email.split('@');
setEditingUser({ ...user, emailId: idPart, password: '' });
setErrors({});
setGlobalError('');
setEditDialogOpen(true);
};
const handleUpdate = async () => {
//
if (!await checkAdminPermission()) return;
setGlobalError('');
if (!validate(editingUser, true)) return;
try {
const fullEmail = `${editingUser.emailId}@caadiq.co.kr`;
if (updateUser) {
await updateUser(editingUser.id, {
...editingUser,
email: fullEmail,
password: editingUser.password || undefined
});
}
setEditDialogOpen(false);
setEditingUser(null);
loadUsers();
} catch (e) {
setGlobalError('수정 실패: ' + (e.message || '오류 발생'));
}
};
// ( )
const confirmDelete = async (user) => {
if (!await checkAdminPermission()) return;
setUserToDelete(user);
setDeleteDialogOpen(true);
};
const handleDelete = async () => {
//
if (!await checkAdminPermission()) return;
if (!userToDelete) return;
try {
await deleteUser(userToDelete.id);
setDeleteDialogOpen(false);
setUserToDelete(null);
loadUsers();
} catch (e) {
console.error(e);
}
};
const filteredUsers = users.filter(u =>
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
(u.name && u.name.toLowerCase().includes(searchQuery.toLowerCase()))
);
const textFieldStyle = {
'& .MuiOutlinedInput-root': {
borderRadius: '12px',
'&.Mui-focused fieldset': { borderColor: '#9ca3af', borderWidth: '1px' }
},
'& .MuiInputLabel-root.Mui-focused': { color: '#6b7280' }
};
if (loading) {
return (
<div className="flex items-center justify-center h-full bg-gray-50">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-500 font-medium">사용자 로딩 ...</span>
</div>
</div>
);
}
return (
<div className="p-8 bg-gray-50 min-h-full">
{/* 헤더 */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-800">사용자 관리</h1>
<p className="text-gray-500 text-sm mt-1">이메일 계정을 추가, 수정, 삭제합니다</p>
</div>
{/* 액션 바 */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div className="relative w-full sm:w-72">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="이름 또는 이메일 검색..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-100 focus:border-blue-300 outline-none transition-all"
/>
</div>
<Button
variant="contained"
onClick={openAddDialog}
startIcon={<UserPlus className="h-5 w-5" />}
sx={{
backgroundColor: '#6366f1',
textTransform: 'none',
borderRadius: '12px',
boxShadow: 'none',
fontWeight: 600,
px: 3,
'&:hover': { backgroundColor: '#4f46e5', boxShadow: 'none' }
}}
>
사용자 추가
</Button>
</div>
{/* 사용자 통계 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-white p-4 rounded-xl border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-blue-100 rounded-xl">
<Users className="text-blue-600" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{users.length}</p>
<p className="text-sm text-gray-500">전체 사용자</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-indigo-100 rounded-xl">
<Shield className="text-indigo-600" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{users.filter(u => u.isAdmin).length}</p>
<p className="text-sm text-gray-500">관리자</p>
</div>
</div>
<div className="bg-white p-4 rounded-xl border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-emerald-100 rounded-xl">
<User className="text-emerald-600" size={20} />
</div>
<div>
<p className="text-2xl font-bold text-gray-800">{users.filter(u => !u.isAdmin).length}</p>
<p className="text-sm text-gray-500">일반 사용자</p>
</div>
</div>
</div>
{/* 사용자 테이블 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-100">
<tr>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 uppercase">사용자</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 uppercase">이름</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 uppercase">권한</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-gray-500 uppercase">생성일</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-500 uppercase">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredUsers.length > 0 ? filteredUsers.map((u) => (
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center">
<div className={`h-10 w-10 rounded-full flex items-center justify-center text-white mr-3 ${u.isAdmin ? 'bg-gradient-to-br from-indigo-500 to-purple-600' : 'bg-gradient-to-br from-gray-400 to-gray-500'}`}>
{u.isAdmin ? <Shield size={18} /> : <User size={18} />}
</div>
<div className="text-sm font-medium text-gray-900">{u.email}</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{u.name || '-'}</td>
<td className="px-6 py-4">
{u.isAdmin ?
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700">
관리자
</span> :
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-gray-100 text-gray-600">
일반
</span>
}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(u.createdAt).toLocaleDateString('ko-KR')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1">
<IconButton
onClick={() => startEdit(u)}
size="small"
sx={{
color: '#6b7280',
'&:hover': { color: '#6366f1', backgroundColor: '#eef2ff' }
}}
>
<Pencil size={16} />
</IconButton>
<IconButton
onClick={() => confirmDelete(u)}
size="small"
sx={{
color: '#6b7280',
'&:hover': { color: '#ef4444', backgroundColor: '#fef2f2' }
}}
>
<Trash2 size={16} />
</IconButton>
</div>
</td>
</tr>
)) : (
<tr>
<td colSpan="5" className="px-6 py-12 text-center text-gray-400">
{searchQuery ? '검색 결과가 없습니다' : '등록된 사용자가 없습니다'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 사용자 추가 다이얼로그 - 자동완성 완전 차단 */}
<Dialog
open={addDialogOpen}
onClose={(_, reason) => reason !== 'backdropClick' && setAddDialogOpen(false)}
TransitionComponent={Transition}
fullWidth maxWidth="xs"
PaperProps={{ sx: { borderRadius: '20px', padding: '8px' } }}
>
<form autoComplete="off" onSubmit={(e) => { e.preventDefault(); handleAdd(); }}>
<div className="text-center p-6">
<div className="mx-auto bg-indigo-100 w-16 h-16 rounded-2xl flex items-center justify-center mb-4">
<PersonAddIcon sx={{ fontSize: 32, color: '#6366f1' }} />
</div>
<DialogTitle sx={{ fontWeight: 700, padding: 0, marginBottom: 1, fontSize: '1.25rem' }}> 사용자 추가</DialogTitle>
<p className="text-sm text-gray-500 mb-6">새로운 계정 정보를 입력해주세요</p>
{globalError && (
<div className="mb-4 text-sm text-red-600 bg-red-50 p-3 rounded-xl">{globalError}</div>
)}
<div className="space-y-4 text-left">
<TextField
label="이메일 (ID)" fullWidth variant="outlined"
value={newUser.email} error={!!errors.email} helperText={errors.email}
name="new_email_id_field"
inputProps={{ autoComplete: 'new-password', 'data-lpignore': 'true', 'data-form-type': 'other' }}
onChange={(e) => { setNewUser({...newUser, email: e.target.value}); setGlobalError(''); setErrors({...errors, email: ''}); }}
InputProps={{
startAdornment: <InputAdornment position="start"><EmailIcon color="action" fontSize="small" /></InputAdornment>,
endAdornment: <InputAdornment position="end"><span className="text-gray-400 text-sm">@caadiq.co.kr</span></InputAdornment>
}}
sx={textFieldStyle}
/>
<TextField
label="이름" fullWidth variant="outlined"
value={newUser.name} error={!!errors.name} helperText={errors.name}
name="new_display_name_field"
inputProps={{ autoComplete: 'new-password', 'data-lpignore': 'true', 'data-form-type': 'other' }}
onChange={(e) => { setNewUser({...newUser, name: e.target.value}); setGlobalError(''); setErrors({...errors, name: ''}); }}
InputProps={{ startAdornment: <InputAdornment position="start"><BadgeIcon color="action" fontSize="small" /></InputAdornment> }}
sx={textFieldStyle}
/>
<TextField
label="비밀번호" type="password" fullWidth variant="outlined"
value={newUser.password} error={!!errors.password} helperText={errors.password}
name="new_secret_key_field"
inputProps={{ autoComplete: 'new-password', 'data-lpignore': 'true', 'data-form-type': 'other' }}
onChange={(e) => { setNewUser({...newUser, password: e.target.value}); setGlobalError(''); setErrors({...errors, password: ''}); }}
InputProps={{ startAdornment: <InputAdornment position="start"><VpnKeyIcon color="action" fontSize="small" /></InputAdornment> }}
sx={textFieldStyle}
/>
<div
className="flex items-center p-3 bg-gray-50 rounded-xl cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => setNewUser({...newUser, isAdmin: !newUser.isAdmin})}
>
<div className={`mr-3 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${newUser.isAdmin ? 'bg-indigo-500 border-indigo-500' : 'border-gray-300'}`}>
{newUser.isAdmin && <Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />}
</div>
<div>
<span className="text-sm font-semibold text-gray-700">관리자 권한 부여</span>
<p className="text-xs text-gray-500">시스템 설정 사용자 관리 가능</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button type="button" onClick={() => setAddDialogOpen(false)} fullWidth variant="outlined"
sx={{ color: '#6b7280', borderColor: '#d1d5db', borderRadius: '12px', height: '48px', textTransform: 'none', fontWeight: 600 }}>
취소
</Button>
<Button type="submit" fullWidth variant="contained"
sx={{ backgroundColor: '#6366f1', borderRadius: '12px', height: '48px', boxShadow: 'none', textTransform: 'none', fontWeight: 600, '&:hover': { backgroundColor: '#4f46e5' } }}>
계정 생성
</Button>
</div>
</div>
</div>
</form>
</Dialog>
{/* 사용자 수정 다이얼로그 */}
<Dialog
open={editDialogOpen}
onClose={(_, reason) => reason !== 'backdropClick' && setEditDialogOpen(false)}
TransitionComponent={Transition}
fullWidth maxWidth="xs"
PaperProps={{ sx: { borderRadius: '20px', padding: '8px' } }}
>
{editingUser && (
<form autoComplete="off" onSubmit={(e) => { e.preventDefault(); handleUpdate(); }}>
<div className="text-center p-6">
<DialogTitle sx={{ fontWeight: 700, padding: 0, marginBottom: 1, fontSize: '1.25rem' }}>사용자 정보 수정</DialogTitle>
{globalError && (
<div className="mb-4 text-sm text-red-600 bg-red-50 p-3 rounded-xl">{globalError}</div>
)}
<div className="space-y-4 text-left mt-4">
<TextField
label="이메일 (변경 불가)" fullWidth variant="outlined"
value={editingUser.emailId} disabled
InputProps={{
endAdornment: <InputAdornment position="end"><span className="text-gray-400 text-sm">@caadiq.co.kr</span></InputAdornment>
}}
sx={{ '& .MuiOutlinedInput-root': { borderRadius: '12px', backgroundColor: '#f9fafb' } }}
/>
<TextField
label="이름" fullWidth variant="outlined"
value={editingUser.name} error={!!errors.name} helperText={errors.name}
name="edit_display_name_field"
inputProps={{ autoComplete: 'new-password', 'data-lpignore': 'true' }}
onChange={(e) => { setEditingUser({...editingUser, name: e.target.value}); setGlobalError(''); setErrors({...errors, name: ''}); }}
sx={textFieldStyle}
/>
<TextField
label="새 비밀번호 (변경시에만 입력)" type="password" fullWidth variant="outlined"
value={editingUser.password} error={!!errors.password} helperText={errors.password}
name="edit_secret_key_field"
inputProps={{ autoComplete: 'new-password', 'data-lpignore': 'true' }}
placeholder="변경하지 않으려면 비워두세요"
onChange={(e) => { setEditingUser({...editingUser, password: e.target.value}); setGlobalError(''); setErrors({...errors, password: ''}); }}
sx={textFieldStyle}
/>
{/* 관리자 권한 체크박스 - 본인 권한은 변경 불가 */}
{(() => {
const isSelf = editingUser.email === currentUser?.email;
return (
<div
className={`flex items-center p-3 bg-gray-50 rounded-xl transition-colors ${isSelf ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-gray-100'}`}
onClick={() => !isSelf && setEditingUser({...editingUser, isAdmin: !editingUser.isAdmin})}
>
<div className={`mr-3 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all ${editingUser.isAdmin ? 'bg-indigo-500 border-indigo-500' : 'border-gray-300'}`}>
{editingUser.isAdmin && <Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />}
</div>
<div>
<span className="text-sm font-semibold text-gray-700">관리자 권한</span>
{isSelf && (
<p className="text-xs text-gray-500">본인 권한은 변경할 없습니다</p>
)}
</div>
</div>
);
})()}
<div className="flex gap-3 mt-6">
<Button type="button" onClick={() => setEditDialogOpen(false)} fullWidth variant="outlined"
sx={{ color: '#6b7280', borderColor: '#d1d5db', borderRadius: '12px', height: '48px', textTransform: 'none', fontWeight: 600 }}>
취소
</Button>
<Button type="submit" fullWidth variant="contained"
sx={{ backgroundColor: '#6366f1', borderRadius: '12px', height: '48px', boxShadow: 'none', textTransform: 'none', fontWeight: 600, '&:hover': { backgroundColor: '#4f46e5' } }}>
수정 저장
</Button>
</div>
</div>
</div>
</form>
)}
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
TransitionComponent={Transition}
fullWidth maxWidth="xs"
PaperProps={{ sx: { borderRadius: '20px', padding: '8px' } }}
>
<div className="text-center p-6">
<div className="mx-auto bg-red-100 w-16 h-16 rounded-2xl flex items-center justify-center mb-4">
<Trash2 className="h-8 w-8 text-red-500" />
</div>
<DialogTitle sx={{ fontWeight: 700, padding: 0, marginBottom: 1, fontSize: '1.25rem' }}>사용자 삭제</DialogTitle>
<p className="text-sm text-gray-500 mb-6">
정말로 <strong className="text-gray-700">{userToDelete?.name}</strong> ({userToDelete?.email}) 님을 삭제하시겠습니까?<br/>
<span className="text-red-500"> 작업은 되돌릴 없습니다.</span>
</p>
<div className="flex gap-3">
<Button onClick={() => setDeleteDialogOpen(false)} fullWidth variant="outlined"
sx={{ color: '#6b7280', borderColor: '#d1d5db', borderRadius: '12px', height: '48px', textTransform: 'none', fontWeight: 600 }}>
취소
</Button>
<Button onClick={handleDelete} fullWidth variant="contained" color="error"
sx={{ borderRadius: '12px', height: '48px', boxShadow: 'none', textTransform: 'none', fontWeight: 600 }}>
삭제
</Button>
</div>
</div>
</Dialog>
</div>
);
};
export default UserManagement;

View file

@ -0,0 +1,44 @@
/**
* 기간 선택 컴포넌트
*/
import React from 'react';
/**
* 기간 선택
* @param {Object} props
* @param {string} props.selected - 선택된 기간 (1d, 7d, 30d, all)
* @param {Function} props.onChange - 기간 변경 콜백
* @param {boolean} props.disabled - 비활성화 여부
*/
const PeriodTabs = ({ selected, onChange, disabled = false }) => {
const options = [
{ value: '1d', label: '1일' },
{ value: '7d', label: '7일' },
{ value: '30d', label: '30일' },
{ value: 'all', label: '전체' },
];
return (
<div className="flex gap-1 bg-gray-100 p-1 rounded-lg">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => !disabled && onChange(opt.value)}
disabled={disabled}
className={`
px-3 py-1.5 text-sm font-medium rounded-md transition-all
${selected === opt.value
? 'bg-white text-gray-800 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
{opt.label}
</button>
))}
</div>
);
};
export default PeriodTabs;

View file

@ -0,0 +1,71 @@
/**
* 통계 카드 컴포넌트
* 대시보드 상단 통계 카드 그리드
*/
import React from 'react';
import { Users, Send, Inbox, ShieldAlert } from 'lucide-react';
/**
* 개별 통계 카드
*/
const StatCard = ({ title, value, icon: Icon, bgColor, iconBg }) => (
<div className={`${bgColor} rounded-2xl p-5 shadow-lg shadow-gray-200/50`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 font-medium">{title}</p>
<p className="text-3xl font-bold text-gray-800 mt-1">{value || '0'}</p>
</div>
<div className={`${iconBg} p-3 rounded-xl`}>
<Icon className="h-6 w-6 text-white" />
</div>
</div>
</div>
);
/**
* 통계 카드 그리드
* @param {Object} props
* @param {Object} props.stats - 통계 데이터
*/
const StatCards = ({ stats }) => {
const cards = [
{
title: '전체 사용자',
value: stats.userCount?.toLocaleString(),
icon: Users,
bgColor: 'bg-gradient-to-br from-white to-blue-50',
iconBg: 'bg-gradient-to-br from-blue-500 to-indigo-600'
},
{
title: '오늘 발송',
value: stats.sentToday?.toLocaleString(),
icon: Send,
bgColor: 'bg-gradient-to-br from-white to-green-50',
iconBg: 'bg-gradient-to-br from-green-500 to-emerald-600'
},
{
title: '오늘 수신',
value: stats.receivedToday?.toLocaleString(),
icon: Inbox,
bgColor: 'bg-gradient-to-br from-white to-purple-50',
iconBg: 'bg-gradient-to-br from-purple-500 to-violet-600'
},
{
title: '스팸 차단',
value: stats.totalSpam?.toLocaleString(),
icon: ShieldAlert,
bgColor: 'bg-gradient-to-br from-white to-red-50',
iconBg: 'bg-gradient-to-br from-red-500 to-rose-600'
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{cards.map((card, index) => (
<StatCard key={index} {...card} />
))}
</div>
);
};
export default StatCards;

View file

@ -0,0 +1,76 @@
/**
* 시스템 상태 카드 컴포넌트
* rspamd, 데이터베이스, 스토리지 상태 표시
*/
import React from 'react';
import { Server, Database, HardDrive } from 'lucide-react';
/**
* 시스템 상태 카드
* @param {Object} props
* @param {Object} props.stats - 통계 데이터 (rspamdStatus, dbStatus, storageUsed, storageLimit)
*/
const SystemStatus = ({ stats }) => {
const isRspamdRunning = stats.rspamdStatus === 'Running';
const isDbConnected = stats.dbStatus === 'Connected';
//
const storageUsedMB = parseFloat(stats.storageUsed) || 0;
const storageLimitMB = stats.storageLimit || 51200;
const storagePercent = Math.min((storageUsedMB / storageLimitMB) * 100, 100).toFixed(1);
return (
<div className="bg-white rounded-2xl shadow-lg shadow-gray-200/50 p-5">
<h3 className="text-lg font-bold text-gray-800 mb-4 flex items-center gap-2">
<Server className="h-5 w-5 text-gray-500" />
시스템 상태
</h3>
<div className="space-y-4">
{/* rspamd 상태 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${isRspamdRunning ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-gray-600">rspamd</span>
</div>
<span className={`text-sm font-medium ${isRspamdRunning ? 'text-green-600' : 'text-red-600'}`}>
{isRspamdRunning ? '실행 중' : '중지됨'}
</span>
</div>
{/* 데이터베이스 상태 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2.5 h-2.5 rounded-full ${isDbConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-gray-600">데이터베이스</span>
</div>
<span className={`text-sm font-medium ${isDbConnected ? 'text-green-600' : 'text-red-600'}`}>
{isDbConnected ? '연결됨' : '연결 끊김'}
</span>
</div>
{/* 스토리지 */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">스토리지</span>
</div>
<span className="text-sm text-gray-500">
{stats.storageUsed} / {(storageLimitMB / 1024).toFixed(0)}GB
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-gradient-to-r from-blue-500 to-indigo-600 h-2 rounded-full transition-all duration-500"
style={{ width: `${storagePercent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1 text-right">{storagePercent}% 사용</p>
</div>
</div>
</div>
);
};
export default SystemStatus;

View file

@ -0,0 +1,6 @@
/**
* admin/dashboard 컴포넌트 인덱스
*/
export { default as StatCards } from "./StatCards";
export { default as SystemStatus } from "./SystemStatus";
export { default as PeriodTabs } from "./PeriodTabs";

View file

@ -0,0 +1,35 @@
/**
* 첨부파일 목록 컴포넌트
* 첨부된 파일 표시 삭제
*/
import React from 'react';
import { Paperclip, X } from 'lucide-react';
/**
* 첨부파일 목록 UI
* @param {Object} props
* @param {File[]} props.attachments - 첨부파일 배열
* @param {Function} props.onRemove - 첨부파일 삭제 콜백 (index)
*/
const AttachmentList = ({ attachments, onRemove }) => {
if (attachments.length === 0) return null;
return (
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50">
<div className="flex flex-wrap gap-2">
{attachments.map((file, index) => (
<div key={index} className="flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm">
<Paperclip size={14} className="text-gray-400" />
<span className="text-gray-700 max-w-[200px] truncate">{file.name}</span>
<span className="text-gray-400 text-xs">({(file.size / 1024).toFixed(1)}KB)</span>
<button onClick={() => onRemove(index)} className="text-gray-400 hover:text-red-500">
<X size={14} />
</button>
</div>
))}
</div>
</div>
);
};
export default AttachmentList;

View file

@ -0,0 +1,45 @@
/**
* 서식 툴바 컴포넌트
* 텍스트 서식 버튼 (굵게, 기울임, 밑줄)
*/
import React from 'react';
import { Bold, Italic, Underline } from 'lucide-react';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import Fade from '@mui/material/Fade';
/**
* 서식 툴바 UI
* @param {Object} props
* @param {Object} props.activeFormats - 활성화된 서식 상태 { bold, italic, underline }
* @param {Function} props.onApplyFormat - 서식 적용 콜백 (command)
*/
const FormatToolbar = ({ activeFormats, onApplyFormat }) => {
const buttons = [
{ command: 'bold', icon: Bold, title: '굵게', active: activeFormats.bold },
{ command: 'italic', icon: Italic, title: '기울임', active: activeFormats.italic },
{ command: 'underline', icon: Underline, title: '밑줄', active: activeFormats.underline },
];
return (
<div className="absolute bottom-20 left-5 bg-white border border-gray-200 rounded-lg shadow-lg p-2 flex items-center gap-1">
{buttons.map(({ command, icon: Icon, title, active }) => (
<Tooltip key={command} title={title} arrow placement="top" TransitionComponent={Fade}>
<IconButton
size="small"
onClick={() => onApplyFormat(command)}
sx={{
color: active ? '#6366f1' : '#6b7280',
backgroundColor: active ? '#eef2ff' : 'transparent',
'&:hover': { backgroundColor: '#f3f4f6' }
}}
>
<Icon size={16} />
</IconButton>
</Tooltip>
))}
</div>
);
};
export default FormatToolbar;

View file

@ -0,0 +1,77 @@
/**
* 링크 삽입 다이얼로그 컴포넌트
*/
import React, { useState } from 'react';
import toast from 'react-hot-toast';
/**
* 링크 삽입 다이얼로그 UI
* @param {Object} props
* @param {string} props.initialText - 선택된 텍스트 (초기값)
* @param {Function} props.onInsert - 링크 삽입 콜백 (text, url)
* @param {Function} props.onClose - 닫기 콜백
*/
const LinkDialog = ({ initialText = '', onInsert, onClose }) => {
const [linkText, setLinkText] = useState(initialText);
const [linkUrl, setLinkUrl] = useState('');
const handleInsert = () => {
if (!linkUrl) {
toast.error('URL을 입력하세요');
return;
}
onInsert(linkText || linkUrl, linkUrl);
};
const handleClose = () => {
setLinkText('');
setLinkUrl('');
onClose();
};
return (
<div className="absolute inset-0 z-50 bg-white/95 flex items-center justify-center p-8 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-6 max-w-md w-full">
<h3 className="font-bold text-gray-800 text-lg mb-4">링크 삽입</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">표시 텍스트</label>
<input
type="text"
value={linkText}
onChange={(e) => setLinkText(e.target.value)}
placeholder="링크 텍스트"
className="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-600 mb-1">URL</label>
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="w-full px-3 py-2 border border-gray-200 rounded-lg outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={handleClose}
className="px-4 py-2 border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50 font-medium text-sm"
>
취소
</button>
<button
onClick={handleInsert}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium text-sm"
>
삽입
</button>
</div>
</div>
</div>
);
};
export default LinkDialog;

View file

@ -0,0 +1,188 @@
/**
* 수신자 입력 컴포넌트
* 이메일 주소 입력, 태그 형태 표시, 자동완성
*/
import React, { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import toast from 'react-hot-toast';
/**
* 이메일 유효성 검사
*/
const validateEmail = (email) => {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email.trim());
};
/**
* 수신자 입력 UI
* @param {Object} props
* @param {string[]} props.recipients - 현재 수신자 목록
* @param {Function} props.setRecipients - 수신자 목록 setter
* @param {boolean} props.autoFocus - 자동 포커스 여부
* @param {React.RefObject} props.subjectInputRef - 제목 입력창 ref (Tab 이동용)
*/
const RecipientInput = ({ recipients, setRecipients, autoFocus = false, subjectInputRef }) => {
const [inputValue, setInputValue] = useState('');
const [userList, setUserList] = useState([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredUsers, setFilteredUsers] = useState([]);
const inputRef = useRef(null);
// ( )
useEffect(() => {
const fetchUsers = async () => {
try {
const token = localStorage.getItem('email_token');
const res = await fetch('/api/emails?mailbox=INBOX&page=1&limit=100', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
const uniqueEmails = new Set();
data.emails.forEach(email => {
const emailMatch = email.from.match(/([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/);
if (emailMatch) {
uniqueEmails.add(emailMatch[1]);
}
});
const users = Array.from(uniqueEmails).map(email => ({
email: email,
name: email.split('@')[0]
}));
setUserList(users);
}
} catch (error) {
console.error('사용자 목록 조회 오류:', error);
}
};
fetchUsers();
}, []);
const handleInputChange = (e) => {
const value = e.target.value;
setInputValue(value);
//
if (value.trim()) {
const filtered = userList.filter(user =>
user.email.toLowerCase().includes(value.toLowerCase()) ||
user.name?.toLowerCase().includes(value.toLowerCase())
);
setFilteredUsers(filtered);
setShowSuggestions(filtered.length > 0);
} else {
setShowSuggestions(false);
}
};
const addRecipient = () => {
const email = inputValue.trim();
if (email) {
if (validateEmail(email)) {
if (!recipients.includes(email)) {
setRecipients([...recipients, email]);
}
setInputValue('');
} else {
toast.error('올바른 이메일 형식이 아닙니다.');
}
}
};
const selectUser = (userEmail) => {
if (!recipients.includes(userEmail)) {
setRecipients([...recipients, userEmail]);
}
setInputValue('');
setShowSuggestions(false);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addRecipient();
setShowSuggestions(false);
} else if (e.key === 'Backspace' && inputValue === '' && recipients.length > 0) {
setRecipients(recipients.slice(0, -1));
} else if (e.key === 'Tab') {
e.preventDefault();
if (inputValue.trim()) {
addRecipient();
}
setShowSuggestions(false);
setTimeout(() => {
subjectInputRef?.current?.focus();
}, 0);
} else if (e.key === 'Escape') {
setShowSuggestions(false);
}
};
const handleBlur = () => {
setShowSuggestions(false);
};
const removeRecipient = (index) => {
setRecipients(prev => prev.filter((_, i) => i !== index));
};
return (
<div className="relative flex items-center border-b border-gray-100 px-5 py-3">
<label className="w-16 flex-shrink-0 text-sm text-gray-600">받는사람</label>
<div className="flex-1">
<div className="flex flex-wrap gap-2 items-center">
{recipients.map((email, index) => (
<div key={index} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-sm">
<span>{email}</span>
<button onClick={() => removeRecipient(index)} className="hover:bg-blue-200 rounded-full p-0.5">
<X size={14} />
</button>
</div>
))}
<input
ref={inputRef}
type="text"
className="flex-1 min-w-[200px] py-1 outline-none text-gray-800 text-sm placeholder-gray-400"
autoFocus={autoFocus}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={() => {
if (inputValue.trim() && filteredUsers.length > 0) {
setShowSuggestions(true);
}
}}
placeholder="이메일 주소 입력 (Tab으로 다음)"
/>
</div>
{/* 자동완성 드롭다운 */}
{showSuggestions && (
<div className="absolute left-16 right-5 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50">
{filteredUsers.map((user, index) => (
<div
key={index}
onMouseDown={(e) => {
e.preventDefault();
selectUser(user.email);
}}
className="px-4 py-2 hover:bg-blue-50 cursor-pointer flex items-center justify-between"
>
<div>
<div className="text-sm font-medium text-gray-800">{user.email}</div>
{user.name && <div className="text-xs text-gray-500">{user.name}</div>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RecipientInput;

View file

@ -0,0 +1,7 @@
/**
* compose 컴포넌트 인덱스
*/
export { default as AttachmentList } from "./AttachmentList";
export { default as RecipientInput } from "./RecipientInput";
export { default as FormatToolbar } from "./FormatToolbar";
export { default as LinkDialog } from "./LinkDialog";

View file

@ -0,0 +1,137 @@
/* Custom DatePicker Styles */
.react-datepicker {
font-family: inherit;
border: none !important;
border-radius: 16px !important;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1) !important;
padding: 16px;
animation: datepicker-fade-in 0.2s ease-out;
}
@keyframes datepicker-fade-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes datepicker-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
.react-datepicker-closing {
animation: datepicker-fade-out 0.2s ease-out forwards !important;
}
.custom-datepicker-wrapper .react-datepicker-wrapper,
.custom-datepicker-wrapper .react-datepicker__input-container {
display: block;
width: 100%;
}
.react-datepicker__header {
background-color: white !important;
border-bottom: none !important;
padding-top: 0 !important;
}
.react-datepicker__current-month {
font-size: 1rem !important;
font-weight: 600 !important;
margin-bottom: 16px !important;
color: #1f2937;
}
.react-datepicker__day-names {
margin-bottom: 8px;
}
.react-datepicker__day-name {
color: #9ca3af !important;
font-weight: 500 !important;
width: 2.5rem !important;
line-height: 2.5rem !important;
margin: 0 !important;
}
.react-datepicker__day {
width: 2.5rem !important;
line-height: 2.5rem !important;
margin: 0 !important;
border-radius: 50% !important;
color: #374151;
}
.react-datepicker__day:hover {
background-color: #f3f4f6 !important;
}
.react-datepicker__day--disabled2 {
/* unused placeholder */
}
.react-datepicker__day.react-datepicker__day--disabled {
color: #d1d5db !important;
cursor: not-allowed !important;
background-color: transparent !important;
pointer-events: none !important;
opacity: 0.5 !important;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: #6586c6 !important;
color: white !important;
}
.react-datepicker__day--today {
font-weight: bold;
color: #6586c6;
}
.react-datepicker__day--keyboard-selected {
background-color: transparent !important;
color: #374151 !important;
}
.react-datepicker__day--selected:hover {
background-color: #5a77b0 !important;
}
.react-datepicker__day--outside-month {
color: #d1d5db !important;
}
.react-datepicker__navigation {
top: 18px !important;
}
.react-datepicker__navigation--previous {
left: 20px !important;
}
.react-datepicker__navigation--next {
right: 20px !important;
}
.react-datepicker__navigation-icon::before {
border-color: #6b7280 !important;
border-width: 2px 2px 0 0 !important;
height: 8px !important;
width: 8px !important;
}
.react-datepicker__triangle {
display: none !important;
}

View file

@ -0,0 +1,170 @@
/**
* 메일 목록 아이템 컴포넌트
* 개별 메일 표시
*/
import React from 'react';
import { Square, Check, Paperclip, Star } from 'lucide-react';
/**
* 날짜 포맷 헬퍼
*/
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const diffSeconds = Math.floor(diff / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 60) return `${diffSeconds}초 전`;
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
const year = date.getFullYear();
const currentYear = now.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (year === currentYear) return `${month}${day}`;
return `${year}${month}${day}`;
};
/**
* 이메일 읽음 여부 확인
*/
const isEmailRead = (email) => {
return email.isRead || (email.flags && email.flags.includes('\\Seen'));
};
/**
* 첨부파일 여부 확인
*/
const hasAttachments = (email) => {
if (!email.attachments) return false;
let atts = email.attachments;
if (typeof atts === 'string') {
try {
atts = JSON.parse(atts);
} catch {
return false;
}
}
return Array.isArray(atts) && atts.length > 0;
};
/**
* 이메일에서 이름/주소 추출
*/
const extractSender = (fromStr) => {
if (!fromStr) return '';
const match = fromStr.match(/^"?([^"<]+)"?\s*<?/);
if (match) return match[1].trim();
return fromStr.split('@')[0];
};
/**
* 미리보기 텍스트 정리
*/
const cleanPreviewText = (text, html) => {
let preview = text || html || '';
// HTML
preview = preview.replace(/<[^>]*>/g, '');
// HTML
preview = preview.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"');
//
preview = preview.replace(/\s+/g, ' ').trim();
return preview.slice(0, 100);
};
/**
* 메일 목록 아이템 UI
* @param {Object} props
* @param {Object} props.email - 이메일 데이터
* @param {boolean} props.isChecked - 체크 여부
* @param {boolean} props.isSelected - 선택(상세보기) 여부
* @param {string} props.selectedBox - 현재 메일함
* @param {Function} props.onCheck - 체크 토글 콜백
* @param {Function} props.onClick - 클릭 콜백
*/
const MailListItem = ({
email,
isChecked,
isSelected,
selectedBox,
onCheck,
onClick,
}) => {
const read = isEmailRead(email);
const hasAtts = hasAttachments(email);
const isStarred = email.flags?.includes('\\Flagged');
// /
const isSentBox = selectedBox === 'SENT' || selectedBox === 'DRAFTS';
const displayAddress = isSentBox ? email.to : extractSender(email.from);
return (
<div
onClick={onClick}
className={`
group flex items-center gap-3 px-4 py-3.5 transition-all duration-150
border-b border-slate-100 cursor-pointer
${isSelected
? 'border-l-4 border-l-blue-500 bg-white'
: !read
? 'bg-blue-50/40 hover:bg-blue-50/60'
: 'bg-white hover:bg-slate-50/80'
}
`}
>
{/* 체크박스 */}
<div
onClick={(e) => { e.stopPropagation(); onCheck(e, email.id); }}
className="flex-shrink-0 p-1 -m-1 rounded hover:bg-gray-100 transition-colors"
>
{isChecked ? (
<div className="w-5 h-5 bg-blue-500 rounded flex items-center justify-center">
<Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />
</div>
) : (
<Square className="h-5 w-5 text-gray-300 group-hover:text-gray-400" />
)}
</div>
{/* 별표 */}
<div className="flex-shrink-0 w-5">
{isStarred && <Star className="h-4 w-4 text-yellow-400 fill-yellow-400" />}
</div>
{/* 발신자/수신자 */}
<span className={`w-36 flex-shrink-0 truncate text-sm ${!read ? 'font-semibold text-gray-900' : 'text-gray-700'}`}>
{displayAddress}
</span>
{/* 제목 + 미리보기 */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className={`truncate text-sm ${!read ? 'font-semibold text-gray-900' : 'text-gray-700'}`}>
{email.subject || '(제목 없음)'}
</span>
<span className="text-gray-400 text-sm truncate hidden sm:inline">
- {cleanPreviewText(email.text, email.html)}
</span>
</div>
{/* 첨부파일 아이콘 */}
{hasAtts && (
<Paperclip className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
{/* 날짜 */}
<span className="text-xs text-gray-400 flex-shrink-0 w-20 text-right">
{formatDate(email.date)}
</span>
</div>
);
};
export default MailListItem;

View file

@ -0,0 +1,247 @@
/**
* 메일 목록 툴바 컴포넌트
* 전체선택, 새로고침, 읽음/안읽음, 삭제, 이동 버튼
*/
import React, { useState, useRef, useEffect } from 'react';
import { Square, RotateCw, Check, Trash2, Mail, MailOpen, Minus, ChevronLeft, ChevronRight, MoreVertical, FolderInput, AlertOctagon, ArchiveRestore } from 'lucide-react';
import Tooltip from '@mui/material/Tooltip';
import Fade from '@mui/material/Fade';
/**
* 메일 목록 툴바 UI
* @param {Object} props
* @param {boolean} props.isAllSelected - 전체 선택 여부
* @param {boolean} props.isIndeterminate - 일부 선택 여부
* @param {number} props.selectedCount - 선택된 메일
* @param {boolean} props.hasUnreadInSelection - 선택 안읽은 메일 있는지
* @param {string} props.selectedBox - 현재 메일함
* @param {string} props.paginationText - 페이지네이션 텍스트
* @param {number} props.page - 현재 페이지
* @param {number} props.totalPages - 전체 페이지
* @param {Function} props.onToggleAll - 전체 선택 토글
* @param {Function} props.onRefresh - 새로고침
* @param {Function} props.onMarkAsRead - 읽음/안읽음 처리
* @param {Function} props.onDelete - 삭제
* @param {Function} props.onMoveTo - 이동
* @param {Function} props.onRestore - 복구 (휴지통)
* @param {Function} props.onDeleteAll - 전체 삭제
* @param {Function} props.onPrevPage - 이전 페이지
* @param {Function} props.onNextPage - 다음 페이지
* @param {boolean} props.loading - 로딩
*/
const MailListToolbar = ({
isAllSelected,
isIndeterminate,
selectedCount,
hasUnreadInSelection,
selectedBox,
paginationText,
page,
totalPages,
onToggleAll,
onRefresh,
onMarkAsRead,
onDelete,
onMoveTo,
onRestore,
onDeleteAll,
onPrevPage,
onNextPage,
loading = false,
}) => {
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moveMenuOpen, setMoveMenuOpen] = useState(false);
const moreMenuRef = useRef(null);
const moveMenuRef = useRef(null);
//
useEffect(() => {
const handleClickOutside = (e) => {
if (moreMenuRef.current && !moreMenuRef.current.contains(e.target)) {
setMoreMenuOpen(false);
}
if (moveMenuRef.current && !moveMenuRef.current.contains(e.target)) {
setMoveMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const markAsReadIcon = hasUnreadInSelection
? <MailOpen className="h-5 w-5" />
: <Mail className="h-5 w-5" />;
// ( )
const moveTargets = [
{ name: 'INBOX', label: '받은편지함' },
{ name: 'SPAM', label: '스팸함' },
{ name: 'IMPORTANT', label: '중요편지함' },
{ name: 'TRASH', label: '휴지통' },
].filter(box => box.name !== selectedBox);
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200/80 bg-white/50 backdrop-blur-sm">
<div className="flex items-center gap-2">
{/* 전체선택 */}
<Tooltip title={isAllSelected ? "전체 선택 해제" : "전체 선택"} placement="bottom" TransitionComponent={Fade}>
<button
onClick={onToggleAll}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
{isAllSelected ? (
<div className="w-5 h-5 bg-blue-500 rounded flex items-center justify-center">
<Check className="h-3.5 w-3.5 text-white" strokeWidth={3} />
</div>
) : isIndeterminate ? (
<div className="w-5 h-5 bg-blue-500 rounded flex items-center justify-center">
<Minus className="h-3.5 w-3.5 text-white" strokeWidth={3} />
</div>
) : (
<Square className="h-5 w-5 text-gray-400" />
)}
</button>
</Tooltip>
{/* 새로고침 */}
<Tooltip title="새로고침" placement="bottom" TransitionComponent={Fade}>
<button
onClick={onRefresh}
className={`p-2 rounded-lg hover:bg-gray-100 transition-colors ${loading ? 'animate-spin' : ''}`}
>
<RotateCw className="h-5 w-5 text-gray-500" />
</button>
</Tooltip>
{/* 선택 시 표시되는 버튼들 */}
{selectedCount > 0 && (
<>
<div className="h-5 w-px bg-gray-200 mx-1" />
{/* 읽음/안읽음 */}
<Tooltip title={hasUnreadInSelection ? "읽음으로 표시" : "안읽음으로 표시"} placement="bottom" TransitionComponent={Fade}>
<button
onClick={onMarkAsRead}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
{markAsReadIcon}
</button>
</Tooltip>
{/* 삭제 */}
<Tooltip title={selectedBox === 'TRASH' ? "영구 삭제" : "삭제"} placement="bottom" TransitionComponent={Fade}>
<button
onClick={onDelete}
className="p-2 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
>
<Trash2 className="h-5 w-5" />
</button>
</Tooltip>
{/* 복구 (휴지통에서만) */}
{selectedBox === 'TRASH' && (
<Tooltip title="복구" placement="bottom" TransitionComponent={Fade}>
<button
onClick={onRestore}
className="p-2 rounded-lg hover:bg-green-50 text-gray-500 hover:text-green-500 transition-colors"
>
<ArchiveRestore className="h-5 w-5" />
</button>
</Tooltip>
)}
{/* 이동 드롭다운 */}
<div className="relative" ref={moveMenuRef}>
<Tooltip title="이동" placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => setMoveMenuOpen(!moveMenuOpen)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<FolderInput className="h-5 w-5 text-gray-500" />
</button>
</Tooltip>
{moveMenuOpen && (
<div className="absolute top-full left-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 min-w-[140px]">
{moveTargets.map(box => (
<button
key={box.name}
onClick={() => {
onMoveTo(box.name);
setMoveMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{box.label}
</button>
))}
</div>
)}
</div>
{/* 더보기 메뉴 */}
<div className="relative" ref={moreMenuRef}>
<Tooltip title="더보기" placement="bottom" TransitionComponent={Fade}>
<button
onClick={() => setMoreMenuOpen(!moreMenuOpen)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<MoreVertical className="h-5 w-5 text-gray-500" />
</button>
</Tooltip>
{moreMenuOpen && (
<div className="absolute top-full left-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 min-w-[160px]">
{selectedBox === 'SPAM' && (
<button
onClick={() => {
onMoveTo('INBOX');
setMoreMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
스팸 아님
</button>
)}
<button
onClick={() => {
onDeleteAll();
setMoreMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<AlertOctagon className="h-4 w-4" />
전체 삭제
</button>
</div>
)}
</div>
</>
)}
</div>
{/* 페이지네이션 */}
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>{paginationText}</span>
<div className="flex items-center gap-1">
<button
onClick={onPrevPage}
disabled={page <= 1}
className="p-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
onClick={onNextPage}
disabled={page >= totalPages}
className="p-1.5 rounded-lg hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
</div>
</div>
);
};
export default MailListToolbar;

View file

@ -0,0 +1,5 @@
/**
* mail 컴포넌트 인덱스
*/
export { default as MailListToolbar } from "./MailListToolbar";
export { default as MailListItem } from "./MailListItem";

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
/**
* 커스텀 인덱스
* 모든 커스텀 훅을 곳에서 export
*/
export { useAuth, getAuthHeaders } from "./useAuth";
export { useEmails, parseEmailData } from "./useEmails";
export { useEmailActions } from "./useEmailActions";
export { useAdmin } from "./useAdmin";
export { useSSE } from "./useSSE";

View file

@ -0,0 +1,218 @@
/**
* 관리자 API 관련 커스텀
* 통계, 사용자 관리, SES 설정
*/
import { useCallback } from "react";
import { getAuthHeaders } from "./useAuth";
/**
* 관리자 기능을 제공하는
*/
export const useAdmin = () => {
/**
* 관리자 API 403 오류 처리
* 권한 박탈 강제 리다이렉트
*/
const handleAdminError = useCallback(async (res) => {
if (res.status === 403) {
// 권한 없음 - 로컬 스토리지 업데이트
const currentUser = JSON.parse(
localStorage.getItem("email_user") || "{}"
);
currentUser.isAdmin = false;
localStorage.setItem("email_user", JSON.stringify(currentUser));
// sessionStorage에 메시지 저장 (리다이렉트 후 표시)
sessionStorage.setItem(
"admin_denied_message",
"권한이 없습니다. 메인 화면으로 이동합니다."
);
// 강제 리다이렉트
window.location.href = "/mail/inbox";
// 페이지 이동이 완료될 때까지 영원히 대기 (후속 코드 실행 방지)
return new Promise(() => {});
}
return res;
}, []);
/**
* 대시보드 통계 조회
*/
const fetchStats = useCallback(
async (period = "all") => {
const res = await fetch(`/api/admin/stats?period=${period}`, {
headers: getAuthHeaders(),
});
await handleAdminError(res);
return res.json();
},
[handleAdminError]
);
/**
* 사용자 목록 조회
*/
const fetchUsers = useCallback(async () => {
const res = await fetch("/api/admin/users", { headers: getAuthHeaders() });
await handleAdminError(res);
return res.json();
}, [handleAdminError]);
/**
* 사용자 추가
*/
const addUser = useCallback(
async (userData) => {
const res = await fetch("/api/admin/users", {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(userData),
});
await handleAdminError(res);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "사용자 생성 실패");
}
return res.json();
},
[handleAdminError]
);
/**
* 사용자 수정
*/
const updateUser = useCallback(
async (id, userData) => {
const res = await fetch(`/api/admin/users/${id}`, {
method: "PUT",
headers: getAuthHeaders(),
body: JSON.stringify(userData),
});
await handleAdminError(res);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "사용자 수정 실패");
}
return res.json();
},
[handleAdminError]
);
/**
* 사용자 삭제
*/
const deleteUser = useCallback(
async (id) => {
const res = await fetch(`/api/admin/users/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
await handleAdminError(res);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "사용자 삭제 실패");
}
return res.json();
},
[handleAdminError]
);
/**
* 이메일 설정 조회
*/
const fetchEmailConfig = useCallback(async () => {
const res = await fetch("/api/admin/config/email", {
headers: getAuthHeaders(),
});
await handleAdminError(res);
return res.json();
}, [handleAdminError]);
/**
* 이메일 설정 저장
*/
const updateEmailConfig = useCallback(
async (config) => {
const res = await fetch("/api/admin/config/email", {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(config),
});
await handleAdminError(res);
if (!res.ok) throw new Error("설정 업데이트 실패");
return res.json();
},
[handleAdminError]
);
/**
* 이메일 연결 테스트
*/
const testEmailConnection = useCallback(
async (emailConfig) => {
const res = await fetch("/api/admin/config/email/test", {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify(emailConfig),
});
await handleAdminError(res);
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || "연결 실패");
}
return res.json();
},
[handleAdminError]
);
/**
* 접속 IP 목록 조회
*/
const fetchRemoteIps = useCallback(
async (page = 1, limit = 10, period = "all") => {
const res = await fetch(
`/api/admin/remote-ips?page=${page}&limit=${limit}&period=${period}`,
{
headers: getAuthHeaders(),
}
);
await handleAdminError(res);
return res.json();
},
[handleAdminError]
);
/**
* 최근 활동 로그 조회
*/
const fetchRecentLogs = useCallback(
async (page = 1, limit = 10) => {
const res = await fetch(
`/api/admin/recent-logs?page=${page}&limit=${limit}`,
{
headers: getAuthHeaders(),
}
);
await handleAdminError(res);
return res.json();
},
[handleAdminError]
);
return {
fetchStats,
fetchUsers,
addUser,
updateUser,
deleteUser,
fetchEmailConfig,
updateEmailConfig,
testEmailConnection,
fetchRemoteIps,
fetchRecentLogs,
};
};
export default useAdmin;

View file

@ -0,0 +1,162 @@
/**
* 인증 관련 커스텀
* 로그인, 로그아웃, 세션 관리
*/
import { useState, useEffect } from "react";
/**
* API 요청용 인증 헤더 생성
*/
export const getAuthHeaders = () => {
const token = localStorage.getItem("email_token");
return token
? { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
: { "Content-Type": "application/json" };
};
/**
* 인증 상태 기능을 제공하는
*/
export const useAuth = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [error, setError] = useState(null);
const [userNames, setUserNames] = useState({}); // 이메일 -> 이름 매핑
/**
* 로컬 스토리지에서 초기 세션 복원 + 서버에서 최신 정보 가져오기
*/
useEffect(() => {
const initSession = async () => {
const savedUser = localStorage.getItem("email_user");
const token = localStorage.getItem("email_token");
if (savedUser && token) {
// 일단 로컬 저장 데이터로 빠르게 복원
const parsedUser = JSON.parse(savedUser);
setUser({ ...parsedUser, token });
// 서버에서 최신 사용자 정보 가져오기 (권한 변경사항 반영)
try {
const res = await fetch("/api/verify", {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
if (data.valid && data.user) {
// 최신 정보로 업데이트
const updatedUser = { ...data.user, token };
setUser(updatedUser);
localStorage.setItem("email_user", JSON.stringify(data.user));
// 관리자 페이지에서 권한이 없으면 즉시 리다이렉트
if (
window.location.pathname.startsWith("/admin") &&
!data.user.isAdmin
) {
sessionStorage.setItem(
"admin_denied_message",
"권한이 없습니다. 메인 화면으로 이동합니다."
);
window.location.href = "/mail/inbox";
return;
}
}
} else {
// 토큰이 유효하지 않으면 로그아웃
setUser(null);
localStorage.removeItem("email_user");
localStorage.removeItem("email_token");
}
} catch (err) {
console.error("세션 검증 오류:", err);
}
}
setInitialLoading(false);
};
initSession();
}, []);
/**
* 로그인
*/
const login = async (email, password) => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "로그인 실패");
const userData = { ...data.user, token: data.token };
setUser(userData);
localStorage.setItem("email_user", JSON.stringify(data.user));
localStorage.setItem("email_token", data.token);
return true;
} catch (err) {
console.error("로그인 오류:", err);
setError(err.message);
return false;
} finally {
setLoading(false);
}
};
/**
* 로그아웃 (상태 초기화 콜백 반환)
*/
const logout = (onLogout) => {
setUser(null);
localStorage.removeItem("email_user");
localStorage.removeItem("email_token");
if (onLogout) onLogout();
};
/**
* 사용자 이름 매핑 조회
* 로컬 가입 사용자의 이메일 -> 이름 매핑
*/
const fetchUserNames = async () => {
try {
const res = await fetch("/api/user-names", {
headers: getAuthHeaders(),
});
if (res.ok) {
const data = await res.json();
setUserNames(data);
return data;
}
} catch (err) {
console.error("사용자 이름 조회 오류:", err);
}
return {};
};
// 로그인 후 사용자 이름 매핑 조회
useEffect(() => {
if (user) {
fetchUserNames();
}
}, [user]);
return {
user,
setUser,
loading,
initialLoading,
error,
setError,
userNames,
login,
logout,
fetchUserNames,
};
};
export default useAuth;

View file

@ -0,0 +1,542 @@
/**
* 메일 액션 관련 커스텀
* 발송, 읽음/안읽음, 별표, 삭제, 이동
*/
import { useCallback } from "react";
import { getAuthHeaders } from "./useAuth";
/**
* 메일 액션 기능을 제공하는
* @param {Object} deps - 의존성 객체
* @param {Object} deps.user - 현재 사용자
* @param {string} deps.selectedBox - 현재 선택된 메일함
* @param {number} deps.page - 현재 페이지
* @param {Object} deps.selectedEmail - 현재 선택된 이메일
* @param {Function} deps.setSelectedEmail - 선택 이메일 setter
* @param {Function} deps.setEmails - 이메일 목록 setter
* @param {Function} deps.findMailboxForEmail - 이메일 ID로 메일함 찾기
* @param {Function} deps.fetchCounts - 카운트 새로고침
* @param {Function} deps.fetchEmails - 이메일 목록 새로고침
*/
export const useEmailActions = (deps) => {
const {
user,
selectedBox,
page,
selectedEmail,
setSelectedEmail,
setEmails,
findMailboxForEmail,
fetchCounts,
fetchEmails,
} = deps;
/**
* 메일 발송
*/
const sendEmail = useCallback(
async (to, subject, html, attachments = []) => {
if (!user) return;
try {
// 첨부파일을 base64로 변환
const attachmentPromises = attachments.map((file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve({
filename: file.name,
content: reader.result.split(",")[1], // base64 부분만
contentType: file.type,
});
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
});
const attachmentsData = await Promise.all(attachmentPromises);
const res = await fetch("/api/send", {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
to,
subject,
html,
text: html,
attachments: attachmentsData,
}),
});
if (!res.ok) throw new Error("발송 실패");
fetchCounts();
if (selectedBox === "SENT") fetchEmails("SENT");
return true;
} catch (err) {
console.error("메일 발송 오류:", err);
throw err;
}
},
[user, selectedBox, fetchCounts, fetchEmails]
);
/**
* 임시저장 생성
*/
const saveDraft = useCallback(
async (to, subject, html) => {
if (!user) return;
try {
const res = await fetch("/api/drafts", {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
to,
subject,
html,
text: html,
}),
});
if (!res.ok) throw new Error("임시저장 실패");
const data = await res.json();
fetchCounts();
return data.id;
} catch (err) {
console.error("임시저장 오류:", err);
throw err;
}
},
[user, fetchCounts]
);
/**
* 임시저장 삭제
*/
const deleteDraft = useCallback(
async (id) => {
if (!user) return;
try {
const res = await fetch(`/api/drafts/${id}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
if (!res.ok) throw new Error("임시저장 삭제 실패");
fetchCounts();
return true;
} catch (err) {
console.error("임시저장 삭제 오류:", err);
throw err;
}
},
[user, fetchCounts]
);
/**
* 임시저장 발송 완료 처리
*/
const handleDraftSendComplete = useCallback(
async (draftId) => {
// 1. 임시저장 삭제 (오류는 무시)
if (draftId) {
try {
await deleteDraft(draftId);
} catch (err) {
console.log("[임시저장] 삭제 생략 (이미 없음):", draftId);
}
}
// 2. 상세화면 닫기
setSelectedEmail(null);
// 3. 임시보관함이면 목록 새로고침
if (selectedBox === "DRAFTS") {
await fetchEmails("DRAFTS");
}
},
[selectedBox, deleteDraft, setSelectedEmail, fetchEmails]
);
/**
* 읽음 처리
*/
const markAsRead = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
// 낙관적 UI 업데이트 - emails
setEmails((prev) =>
prev.map((e) => {
if (e.id === id) {
let flags = Array.isArray(e.flags) ? e.flags : [];
if (!flags.includes("\\Seen")) {
return { ...e, isRead: true, flags: [...flags, "\\Seen"] };
}
}
return e;
})
);
// 낙관적 UI 업데이트 - selectedEmail
setSelectedEmail((prev) => {
if (prev && prev.id === id) {
let flags = Array.isArray(prev.flags) ? prev.flags : [];
if (!flags.includes("\\Seen")) {
return { ...prev, isRead: true, flags: [...flags, "\\Seen"] };
}
}
return prev;
});
await fetch(`/api/emails/${id}/read?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
} catch (err) {
console.error("읽음 처리 오류:", err);
}
},
[findMailboxForEmail, setEmails, setSelectedEmail]
);
/**
* 일괄 읽음 처리
*/
const markAllAsRead = useCallback(
async (ids, mailbox) => {
try {
setEmails((prev) =>
prev.map((e) => {
if (ids.includes(e.id)) {
let flags = Array.isArray(e.flags) ? e.flags : [];
if (!flags.includes("\\Seen")) {
return { ...e, isRead: true, flags: [...flags, "\\Seen"] };
}
}
return e;
})
);
await Promise.all(
ids.map((id) => {
const box = mailbox || findMailboxForEmail(id);
return fetch(`/api/emails/${id}/read?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
})
);
} catch (err) {
console.error("일괄 읽음 처리 오류:", err);
}
},
[findMailboxForEmail, setEmails]
);
/**
* 안읽음 처리
*/
const markAsUnread = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
// 낙관적 UI 업데이트 - emails
setEmails((prev) =>
prev.map((e) => {
if (e.id === id) {
let flags = Array.isArray(e.flags) ? e.flags : [];
return {
...e,
isRead: false,
flags: flags.filter((f) => f !== "\\Seen"),
};
}
return e;
})
);
// 낙관적 UI 업데이트 - selectedEmail
setSelectedEmail((prev) => {
if (prev && prev.id === id) {
let flags = Array.isArray(prev.flags) ? prev.flags : [];
return {
...prev,
isRead: false,
flags: flags.filter((f) => f !== "\\Seen"),
};
}
return prev;
});
await fetch(`/api/emails/${id}/unread?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
} catch (err) {
console.error("안읽음 처리 오류:", err);
}
},
[findMailboxForEmail, setEmails, setSelectedEmail]
);
/**
* 별표(중요) 토글
*/
const toggleStar = useCallback(
async (id) => {
try {
const box = findMailboxForEmail(id) || selectedBox;
const res = await fetch(`/api/emails/${id}/star?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
const data = await res.json();
if (data.success) {
await fetchCounts();
return { movedTo: data.movedTo, newEmailId: data.newEmailId };
}
return null;
} catch (err) {
console.error("별표 처리 오류:", err);
return null;
}
},
[selectedBox, findMailboxForEmail, fetchCounts]
);
/**
* 휴지통으로 이동
*/
const moveToTrash = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
if (selectedEmail?.id === id) {
setSelectedEmail(null);
}
const res = await fetch(`/api/emails/${id}/trash?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
const data = await res.json();
await fetchCounts();
await fetchEmails(selectedBox, page);
return data.trashId ? { trashId: data.trashId } : null;
} catch (err) {
console.error("휴지통 이동 오류:", err);
return null;
}
},
[
selectedBox,
page,
selectedEmail,
setSelectedEmail,
findMailboxForEmail,
fetchCounts,
fetchEmails,
]
);
/**
* 휴지통에서 복구
*/
const restoreEmail = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
await fetch(`/api/emails/${id}/restore?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
await fetchCounts();
await fetchEmails(selectedBox, page);
} catch (err) {
console.error("복구 오류:", err);
}
},
[selectedBox, page, findMailboxForEmail, fetchCounts, fetchEmails]
);
/**
* 영구 삭제
*/
const deleteEmail = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
if (selectedEmail?.id === id) {
setSelectedEmail(null);
}
await fetch(`/api/emails/${id}?mailbox=${box}`, {
method: "DELETE",
headers: getAuthHeaders(),
});
await fetchCounts();
await fetchEmails(selectedBox, page);
} catch (err) {
console.error("삭제 오류:", err);
}
},
[
selectedBox,
page,
selectedEmail,
setSelectedEmail,
findMailboxForEmail,
fetchCounts,
fetchEmails,
]
);
/**
* 스팸함으로 이동
*/
const moveToSpam = useCallback(
async (id, mailbox) => {
try {
const box = mailbox || findMailboxForEmail(id);
if (selectedEmail?.id === id) {
setSelectedEmail(null);
}
const res = await fetch(`/api/emails/${id}/spam?mailbox=${box}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
const data = await res.json();
await fetchCounts();
await fetchEmails(selectedBox, page);
return data.spamId ? { spamId: data.spamId } : null;
} catch (err) {
console.error("스팸함 이동 오류:", err);
return null;
}
},
[
selectedBox,
page,
selectedEmail,
setSelectedEmail,
findMailboxForEmail,
fetchCounts,
fetchEmails,
]
);
/**
* 메일 이동 (범용)
*/
const moveEmail = useCallback(
async (id, mailbox, target) => {
try {
const box = mailbox || findMailboxForEmail(id);
if (selectedEmail?.id === id) {
setSelectedEmail(null);
}
const res = await fetch(
`/api/emails/${id}/move?mailbox=${box}&target=${target}`,
{
method: "PATCH",
headers: getAuthHeaders(),
}
);
const data = await res.json();
await fetchCounts();
await fetchEmails(selectedBox, page);
return data;
} catch (err) {
console.error("메일 이동 오류:", err);
return null;
}
},
[
selectedBox,
page,
selectedEmail,
setSelectedEmail,
findMailboxForEmail,
fetchCounts,
fetchEmails,
]
);
/**
* 모든 메일 삭제 (전체 삭제)
*/
const deleteAllEmails = useCallback(
async (mailbox) => {
try {
const emailsRes = await fetch(
`/api/emails?mailbox=${mailbox}&page=1&limit=1000`,
{
headers: getAuthHeaders(),
}
);
const emails = await emailsRes.json();
if (emails.emails && emails.emails.length > 0) {
// 휴지통이면 영구 삭제, 아니면 휴지통으로 이동
if (mailbox === "TRASH") {
for (const email of emails.emails) {
await fetch(`/api/emails/${email.id}?mailbox=TRASH`, {
method: "DELETE",
headers: getAuthHeaders(),
});
}
} else {
for (const email of emails.emails) {
await fetch(`/api/emails/${email.id}/trash?mailbox=${mailbox}`, {
method: "PATCH",
headers: getAuthHeaders(),
});
}
}
}
await fetchCounts();
await fetchEmails(selectedBox, page);
setSelectedEmail(null);
} catch (err) {
console.error("전체 삭제 오류:", err);
}
},
[selectedBox, page, setSelectedEmail, fetchCounts, fetchEmails]
);
return {
sendEmail,
saveDraft,
deleteDraft,
handleDraftSendComplete,
markAsRead,
markAllAsRead,
markAsUnread,
toggleStar,
moveToTrash,
restoreEmail,
deleteEmail,
moveToSpam,
moveEmail,
deleteAllEmails,
};
};
export default useEmailActions;

View file

@ -0,0 +1,221 @@
/**
* 메일 목록 조회 관련 커스텀
* 메일함 목록, 개수, 메일 목록 조회
*/
import { useState, useRef, useCallback } from "react";
import { getAuthHeaders } from "./useAuth";
/**
* 이메일 데이터의 JSON 필드 파싱 (attachments, flags)
*/
export const parseEmailData = (email) => {
if (!email) return null;
const e = { ...email };
try {
if (typeof e.attachments === "string")
e.attachments = JSON.parse(e.attachments);
} catch {
e.attachments = [];
}
try {
if (typeof e.flags === "string") e.flags = JSON.parse(e.flags);
} catch {
e.flags = [];
}
return e;
};
/**
* 메일 조회 기능을 제공하는
*/
export const useEmails = (user) => {
const [emails, setEmails] = useState([]);
const [selectedBox, setSelectedBox] = useState("INBOX");
const [selectedEmail, setSelectedEmail] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalEmails, setTotalEmails] = useState(0);
const [counts, setCounts] = useState({});
const [mailboxes, setMailboxes] = useState([]);
// 캐시 및 Race condition 방지
const mailboxCache = useRef({});
const fetchIdRef = useRef(0);
/**
* 이메일 ID로 메일함 이름 찾기
*/
const findMailboxForEmail = useCallback(
(id) => {
const email = emails.find((e) => e.id === id);
return email?.mailbox || selectedBox;
},
[emails, selectedBox]
);
/**
* 메일함 목록 가져오기
*/
const fetchMailboxes = useCallback(async () => {
setMailboxes([
{ name: "INBOX", label: "받은편지함" },
{ name: "SENT", label: "보낸편지함" },
{ name: "TRASH", label: "휴지통" },
]);
}, []);
/**
* 메일함별 개수 조회
*/
const fetchCounts = useCallback(async () => {
if (!user) return;
try {
const res = await fetch("/api/emails/counts", {
headers: getAuthHeaders(),
});
const data = await res.json();
setCounts({ ...data, _timestamp: Date.now() });
} catch (err) {
console.error("카운트 조회 오류:", err);
}
}, [user]);
/**
* 메일 목록 조회 (페이징)
*/
const fetchEmails = useCallback(
async (boxName = "INBOX", pageNum = 1) => {
if (!user) return;
// 요청 ID 증가 (이전 요청 무효화)
const currentFetchId = ++fetchIdRef.current;
setLoading(true);
setError(null);
setPage(pageNum);
const upperName = boxName.toUpperCase();
const validBoxes = [
"INBOX",
"SENT",
"TRASH",
"SPAM",
"DRAFTS",
"IMPORTANT",
];
const apiBoxName = validBoxes.includes(upperName) ? upperName : "INBOX";
setSelectedBox(apiBoxName);
if (pageNum === 1) setEmails([]);
fetchCounts();
try {
const url = `/api/emails?mailbox=${apiBoxName}&page=${pageNum}&limit=20`;
const res = await fetch(url, {
headers: getAuthHeaders(),
});
const data = await res.json();
// 최신 요청이 아니면 응답 무시 (race condition 방지)
if (currentFetchId !== fetchIdRef.current) {
console.log(
"[fetchEmails] 이전 요청 무시:",
apiBoxName,
currentFetchId,
"!==",
fetchIdRef.current
);
return;
}
const emailList = data.emails || [];
const parsedData = Array.isArray(emailList)
? emailList.map(parseEmailData)
: [];
mailboxCache.current[boxName] = parsedData;
setEmails(parsedData);
setTotalPages(data.totalPages || 1);
setTotalEmails(data.total || 0);
} catch (err) {
// 최신 요청이 아니면 에러도 무시
if (currentFetchId !== fetchIdRef.current) return;
console.error("메일 목록 조회 오류:", err);
setTotalEmails(0);
} finally {
// 최신 요청일 때만 로딩 해제
if (currentFetchId === fetchIdRef.current) {
setLoading(false);
}
}
},
[user, fetchCounts]
);
/**
* 이메일 선택 (상세 조회)
*/
const selectEmail = useCallback(
async (email, markAsReadFn) => {
setSelectedEmail(email);
if (markAsReadFn) markAsReadFn(email.id, email.mailbox);
try {
const box = email.mailbox || selectedBox;
const res = await fetch(`/api/emails/${email.id}?mailbox=${box}`, {
headers: getAuthHeaders(),
});
if (res.ok) {
const fullEmail = await res.json();
setSelectedEmail(parseEmailData(fullEmail));
}
} catch (err) {
console.error("메일 상세 조회 오류:", err);
}
},
[selectedBox]
);
/**
* 상태 초기화 (로그아웃 )
*/
const resetState = useCallback(() => {
setEmails([]);
setMailboxes([]);
setSelectedEmail(null);
setCounts({});
setPage(1);
setTotalPages(1);
setTotalEmails(0);
}, []);
return {
emails,
setEmails,
selectedBox,
setSelectedBox,
selectedEmail,
setSelectedEmail,
loading,
setLoading,
error,
setError,
page,
setPage,
totalPages,
totalEmails,
counts,
setCounts,
mailboxes,
mailboxCache,
findMailboxForEmail,
fetchMailboxes,
fetchCounts,
fetchEmails,
selectEmail,
resetState,
};
};
export default useEmails;

View file

@ -0,0 +1,156 @@
/**
* SSE (Server-Sent Events) 실시간 알림
* 메일 도착 알림 자동 재연결 처리
*/
import { useEffect, useRef } from "react";
import toast from "react-hot-toast";
/**
* SSE 연결 메일 알림 기능을 제공하는
* @param {Object} deps - 의존성 객체
* @param {Object} deps.user - 현재 사용자
* @param {string} deps.selectedBox - 현재 선택된 메일함
* @param {number} deps.page - 현재 페이지
* @param {Function} deps.fetchEmails - 이메일 목록 새로고침
* @param {Function} deps.fetchCounts - 카운트 새로고침
*/
export const useSSE = (deps) => {
const { user, selectedBox, page, fetchEmails, fetchCounts } = deps;
// SSE 연결 상태 및 재연결 관련 ref
const eventSourceRef = useRef(null);
const reconnectTimeoutRef = useRef(null);
const reconnectAttemptRef = useRef(0);
// 최신 값을 참조하기 위한 ref (클로저 문제 해결)
const selectedBoxRef = useRef(selectedBox);
const pageRef = useRef(page);
useEffect(() => {
selectedBoxRef.current = selectedBox;
pageRef.current = page;
}, [selectedBox, page]);
/**
* SSE 연결 함수 (재연결 로직 포함)
*/
const connectSSE = () => {
const token = localStorage.getItem("email_token");
if (!token) return;
// 기존 연결이 있으면 정리
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const eventSource = new EventSource(`/api/events?token=${token}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log("[SSE] 연결됨");
// 연결 성공 시 재연결 시도 횟수 초기화
reconnectAttemptRef.current = 0;
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "new-mail") {
const userEmail = user?.email?.toLowerCase();
const fromString = data.data.from || "";
const toString = data.data.to || "";
// 발신자 이메일 추출
let fromEmail = "";
const emailMatch = fromString.match(/<([^>]+)>/);
if (emailMatch) {
fromEmail = emailMatch[1].toLowerCase();
} else {
fromEmail = fromString.trim().toLowerCase();
}
// 받는 사람 확인 - 자신이 받는 사람인지 체크
const isRecipient = toString.toLowerCase().includes(userEmail);
// 자신이 받는 사람이고, 발신자가 자신이 아닌 경우에만 알림 표시
if (isRecipient && fromEmail !== userEmail) {
toast.success(`새로운 메일이 도착했습니다!\n발신자: ${fromEmail}`, {
id: "new-mail",
duration: 5000,
style: {
background: "#333",
color: "#fff",
borderRadius: "8px",
padding: "12px 16px",
},
});
// INBOX에 있으면 메일 목록도 자동 새로고침
if (selectedBoxRef.current === "INBOX") {
fetchEmails("INBOX", pageRef.current);
}
}
// 카운트 새로고침
fetchCounts();
}
} catch (error) {
console.error("[SSE] 메시지 파싱 오류:", error);
}
};
eventSource.onerror = () => {
console.warn("[SSE] 연결 끊김, 재연결 예약...");
eventSource.close();
eventSourceRef.current = null;
// 지수 백오프로 재연결 시도 (최대 30초)
const baseDelay = 1000; // 1초
const maxDelay = 30000; // 30초
const delay = Math.min(
baseDelay * Math.pow(2, reconnectAttemptRef.current),
maxDelay
);
reconnectAttemptRef.current += 1;
console.log(
`[SSE] ${delay / 1000}초 후 재연결 시도 (시도 횟수: ${
reconnectAttemptRef.current
})`
);
reconnectTimeoutRef.current = setTimeout(() => {
if (user) {
connectSSE();
}
}, delay);
};
};
// SSE 연결 시작 및 정리
useEffect(() => {
if (!user) return;
connectSSE();
return () => {
console.log("[SSE] 연결 종료");
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
};
}, [user]); // user만 의존성으로
return {
eventSourceRef,
reconnectAttemptRef,
};
};
export default useSSE;

128
frontend/src/index.css Normal file
View file

@ -0,0 +1,128 @@
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: "Pretendard", -apple-system, BlinkMacSystemFont, system-ui,
Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR",
"Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
sans-serif;
/* 스크롤바로 인한 세로 스크롤 방지: 가로 스크롤은 허용하되 세로는 화면 높이에 맞춤 */
height: 100dvh;
overflow-y: hidden;
overflow-x: auto;
}
/* HTML 이메일 본문 스타일 - 원본 레이아웃을 최대한 유지 */
.email-html-content {
/* 원본 HTML의 스타일을 덮어쓰지 않도록 최소한의 스타일만 적용 */
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all; /* 단어 단위 줄바꿈 (한글 포함) */
font-weight: 400; /* 텍스트 두께 정상화 */
color: #222; /* 텍스트 색상 변경 */
line-height: 1.6;
}
/* HTML 이메일 내부 링크 스타일 */
.email-html-content a {
color: #2563eb !important;
text-decoration: none;
}
.email-html-content a:hover {
text-decoration: underline;
}
/* contentEditable placeholder */
[contenteditable][data-placeholder]:empty:before {
content: attr(data-placeholder);
color: #9ca3af;
pointer-events: none;
position: absolute;
}
[contenteditable]:focus {
outline: none;
}
/* contentEditable 내부 이미지 크기 제한 */
[contenteditable] img {
max-width: 100% !important;
height: auto !important;
display: block !important;
object-fit: contain !important;
}
/* 인쇄용 스타일 - 이메일 상세만 출력 */
@media print {
/* 사이드바, 헤더, 메일목록 숨김 */
body > div > div:first-child, /* 사이드바 */
body header,
body nav {
display: none !important;
}
/* 인쇄 영역만 표시 */
#print-area {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
padding: 20px !important;
background: white !important;
}
/* print:hidden 클래스 숨김 */
.print\\:hidden {
display: none !important;
}
/* 전체 레이아웃 초기화 */
body {
margin: 0 !important;
padding: 0 !important;
}
}
/* 스크롤바 숨기기 (스크롤 기능은 유지) */
.hide-scrollbar {
-ms-overflow-style: none; /* IE, Edge */
scrollbar-width: none; /* Firefox */
}
.hide-scrollbar::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* 어드민 대시보드 내부 스크롤 영역 스크롤바 숨기기 */
.admin-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
}
.admin-scroll::-webkit-scrollbar {
display: none;
}
/* recharts 차트 클릭 시 검은색 테두리 제거 */
.recharts-wrapper,
.recharts-wrapper *,
.recharts-wrapper svg,
.recharts-wrapper svg *,
.recharts-surface,
.recharts-layer,
.recharts-cartesian-grid,
.recharts-area,
.recharts-curve {
outline: none !important;
box-shadow: none !important;
}
/* recharts 차트 포커스 상태에서도 테두리 제거 */
.recharts-wrapper:focus,
.recharts-wrapper *:focus,
.recharts-surface:focus {
outline: none !important;
box-shadow: none !important;
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View file

@ -0,0 +1,93 @@
/**
* 공통 상수 정의
* 메일함 이름, 상태 코드, 설정값
*/
// 메일함 이름 상수
export const MAILBOX = {
INBOX: "INBOX",
SENT: "SENT",
DRAFTS: "DRAFTS",
TRASH: "TRASH",
SPAM: "SPAM",
IMPORTANT: "IMPORTANT",
};
// 메일함 라벨 (한국어)
export const MAILBOX_LABELS = {
[MAILBOX.INBOX]: "받은편지함",
[MAILBOX.SENT]: "보낸편지함",
[MAILBOX.DRAFTS]: "임시보관함",
[MAILBOX.TRASH]: "휴지통",
[MAILBOX.SPAM]: "스팸함",
[MAILBOX.IMPORTANT]: "중요편지함",
};
// API 응답 상태
export const API_STATUS = {
SUCCESS: "success",
ERROR: "error",
};
// 시스템 상태
export const SYSTEM_STATUS = {
RUNNING: "Running",
CONNECTED: "Connected",
STOPPED: "Stopped",
DISCONNECTED: "Disconnected",
};
// 기간 필터 옵션
export const PERIOD_OPTIONS = [
{ value: "1d", label: "1일" },
{ value: "7d", label: "7일" },
{ value: "30d", label: "30일" },
{ value: "all", label: "전체" },
];
// 날짜 범위 옵션
export const DATE_WITHIN_OPTIONS = [
{ label: "모든 날짜", value: "all" },
{ label: "1일", value: "1d" },
{ label: "1주", value: "1w" },
{ label: "1개월", value: "1m" },
{ label: "6개월", value: "6m" },
{ label: "1년", value: "1y" },
{ label: "직접 입력", value: "custom" },
];
// 검색 범위 옵션
export const SEARCH_SCOPE_OPTIONS = [
{ label: "전체보관함", value: "all" },
{ label: "받은편지함", value: "inbox" },
{ label: "보낸편지함", value: "sent" },
{ label: "중요편지함", value: "important" },
{ label: "임시보관함", value: "drafts" },
{ label: "스팸함", value: "spam" },
{ label: "휴지통", value: "trash" },
];
// 기본 설정값
export const DEFAULTS = {
PAGE_LIMIT: 20,
STORAGE_LIMIT_MB: 51200, // 50GB
SESSION_EXPIRE_HOURS: 24,
TOAST_DURATION: 4000,
UNDO_DURATION: 5000,
};
// 확인 다이얼로그 타입
export const DIALOG_TYPE = {
DANGER: "danger",
WARNING: "warning",
STAR: "star",
SPAM: "spam",
RESTORE: "restore",
};
// 이메일 모드 (작성, 답장, 전달)
export const COMPOSE_MODE = {
COMPOSE: "compose",
REPLY: "reply",
FORWARD: "forward",
};

View file

@ -0,0 +1,21 @@
/**
* HTML 엔티티 디코딩 유틸리티
* &gt; &lt; &amp; &quot; &#39; 등의 HTML 엔티티를 실제 문자로 변환
*/
/**
* HTML 엔티티를 실제 문자로 디코딩
* @param {string} text - 디코딩할 텍스트
* @returns {string} - 디코딩된 텍스트
*/
export const decodeHtmlEntities = (text) => {
if (!text || typeof text !== "string") return text;
// textarea 요소를 사용하여 HTML 엔티티를 자동으로 디코딩
// 브라우저의 내장 파서를 활용하여 모든 HTML 엔티티 처리
const textarea = document.createElement("textarea");
textarea.innerHTML = text;
return textarea.value;
};
export default decodeHtmlEntities;

View file

@ -0,0 +1,81 @@
/**
* 이메일 ID 난독화 유틸리티
* URL에서 직접적인 ID 노출을 방지하기 위한 간단한 난독화
*
* 주의: 이것은 암호화가 아닌 난독화입니다.
* 실제 보안은 백엔드 인증/권한 검사에 의존합니다.
*/
// XOR 키 (간단한 난독화용)
const XOR_KEY = 0x5a3c;
const SALT = 9173;
/**
* 랜덤 문자열 생성 (URL-safe)
*/
const randomChars = (length) => {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
};
/**
* 숫자 ID를 난독화된 문자열로 변환
* @param {number} id - 원본 숫자 ID
* @returns {string} - 난독화된 문자열 ( 24)
*/
export const encodeEmailId = (id) => {
if (!id || typeof id !== "number") return "";
// XOR 변환 + salt
const xored = (id ^ XOR_KEY) + SALT;
// Base64 인코딩
const core = btoa(xored.toString())
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 앞뒤에 랜덤 문자 추가 (prefix 6자 + core + suffix 6자)
const prefix = randomChars(6);
const suffix = randomChars(6);
// 형식: PREFIX + CORE + SUFFIX (prefix 길이를 마지막 문자로 인코딩)
return prefix + core + suffix;
};
/**
* 난독화된 문자열을 원본 숫자 ID로 복원
* @param {string} encoded - 난독화된 문자열
* @returns {number|null} - 원본 숫자 ID 또는 실패 null
*/
export const decodeEmailId = (encoded) => {
if (!encoded || typeof encoded !== "string" || encoded.length < 14)
return null;
try {
// prefix 6자, suffix 6자 제거
const core = encoded.slice(6, -6);
// Base64 복원
let base64 = core.replace(/-/g, "+").replace(/_/g, "/");
while (base64.length % 4) base64 += "=";
const decoded = atob(base64);
const num = parseInt(decoded, 10);
if (isNaN(num)) return null;
// salt 제거 + XOR 복원
const original = (num - SALT) ^ XOR_KEY;
return original > 0 ? original : null;
} catch (e) {
console.error("ID 디코딩 오류:", e);
return null;
}
};

View file

@ -0,0 +1,132 @@
/**
* 날짜, 크기, 시간 포맷팅 유틸리티
* 한국어 날짜 표시, 상대 시간, 파일 크기
*/
/**
* 상대 시간 포맷 ( , , 시간 , 날짜)
* @param {string|Date} dateString - 날짜 문자열 또는 Date 객체
* @returns {string} - 포맷된 상대 시간
*/
export const formatRelativeTime = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diff = now - date;
const diffSeconds = Math.floor(diff / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
if (diffSeconds < 60) return `${diffSeconds}초 전`;
if (diffMinutes < 60) return `${diffMinutes}분 전`;
if (diffHours < 24) return `${diffHours}시간 전`;
const year = date.getFullYear();
const currentYear = now.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (year === currentYear) return `${month}${day}`;
return `${year}${month}${day}`;
};
/**
* 한국어 전체 날짜 포맷 (0000 00 00 오전/오후 00 00)
* @param {string|Date} dateString - 날짜 문자열 또는 Date 객체
* @returns {string} - 포맷된 날짜
*/
export const formatKoreanDate = (dateString) => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, "0");
const ampm = hours < 12 ? "오전" : "오후";
const displayHours = String(hours % 12 || 12).padStart(2, "0");
return `${year}${month}${day}${ampm} ${displayHours}${minutes}`;
};
/**
* 한국어 날짜 포맷 (연도, , , , , 오전/오후)
* @param {string|Date} dateString - 날짜 문자열 또는 Date 객체
* @returns {string} - 포맷된 날짜
*/
export const formatDateFull = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
});
};
/**
* 짧은 날짜 포맷 (, , , )
* @param {string|Date} dateString - 날짜 문자열 또는 Date 객체
* @returns {string} - 포맷된 날짜
*/
export const formatDateShort = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
/**
* 파일 크기 포맷 (바이트 KB/MB/GB)
* @param {number} bytes - 파일 크기 (바이트)
* @returns {string} - 포맷된 파일 크기
*/
export const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
};
/**
* 스토리지 크기 포맷 (MB 적절한 단위)
* @param {number|string} mb - 메가바이트 크기
* @returns {string} - 포맷된 크기
*/
export const formatStorageSize = (mb) => {
const size = parseFloat(mb);
if (isNaN(size)) return "0.00 MB";
if (size >= 1024) return `${(size / 1024).toFixed(2)} GB`;
return `${size.toFixed(2)} MB`;
};
/**
* 숫자를 단위 구분자로 포맷
* @param {number} num - 숫자
* @returns {string} - 포맷된 숫자
*/
export const formatNumber = (num) => {
if (num === undefined || num === null) return "0";
return num.toLocaleString("ko-KR");
};
/**
* 본문 미리보기 텍스트 정리 (HTML, base64 제거)
* @param {string} text - 원본 텍스트
* @param {number} maxLength - 최대 길이 (기본 100)
* @returns {string} - 정리된 텍스트
*/
export const cleanPreviewText = (text, maxLength = 100) => {
if (!text) return "";
return text
.replace(/<[^>]*>/g, "") // HTML 태그 제거
.replace(/&nbsp;/g, " ") // nbsp 엔티티 제거
.replace(/&[a-z]+;/gi, "") // HTML 엔티티 제거
.replace(/data:[^;]+;base64,[A-Za-z0-9+/=]+/gi, "") // data URL 제거
.replace(/[A-Za-z0-9+/=]{50,}/g, "") // 긴 base64 문자열 제거
.replace(/\s+/g, " ") // 연속 공백 정리
.trim()
.substring(0, maxLength);
};

View file

@ -0,0 +1,48 @@
/**
* 검색어 하이라이팅 유틸리티
* 텍스트 내에서 검색어와 일치하는 부분을 하이라이트
*/
import React from 'react';
/**
* 검색어를 하이라이트하는 React 컴포넌트
* @param {string} text - 원본 텍스트
* @param {string} query - 검색어
* @param {string} className - 하이라이트에 적용할 추가 클래스명
* @returns {React.ReactNode} 하이라이트된 텍스트 JSX
*/
export const HighlightText = ({ text, query, className = '' }) => {
//
if (!query?.trim() || !text) {
return <>{text}</>;
}
// ( )
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
//
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return (
<>
{parts.map((part, index) => {
// ( )
const isMatch = part.toLowerCase() === query.toLowerCase();
return isMatch ? (
<mark
key={index}
className={`bg-yellow-200 text-gray-900 px-0.5 rounded ${className}`}
>
{part}
</mark>
) : (
<React.Fragment key={index}>{part}</React.Fragment>
);
})}
</>
);
};
export default HighlightText;

View file

@ -0,0 +1,147 @@
/**
* 유효성 검사 유틸리티
* 이메일, 첨부파일, 입력값 검증
*/
/**
* 이메일 주소 형식 검증
* @param {string} email - 이메일 주소
* @returns {boolean} - 유효 여부
*/
export const validateEmail = (email) => {
if (!email || typeof email !== "string") return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
};
/**
* 이메일 주소에서 로컬 파트(@ 앞부분) 추출
* @param {string} email - 이메일 주소
* @returns {string} - 로컬 파트
*/
export const getEmailLocalPart = (email) => {
if (!email || typeof email !== "string") return "";
return email.split("@")[0] || "";
};
/**
* 이메일에서 발신자 정보 파싱
* "이름" <email@domain.com> 또는 이름 <email@domain.com> 형식 파싱
* @param {string} fromStr - From 헤더 문자열
* @returns {{ name: string, email: string }} - 파싱된 정보
*/
export const parseSenderInfo = (fromStr) => {
if (!fromStr) return { name: "알 수 없음", email: "" };
// "이름" <email@domain.com> 또는 이름 <email@domain.com> 형식
const match = fromStr.match(/^(.*?)\s*<(.*?)>$/);
if (match) {
const name = match[1].replace(/['"]/g, "").trim();
const email = match[2];
return { name: name || email, email };
}
// 이메일만 있는 경우 (email@domain.com)
if (fromStr.includes("@")) {
return { name: fromStr, email: fromStr };
}
return { name: fromStr, email: "" };
};
/**
* 이메일이 읽음 상태인지 확인
* @param {Object} email - 이메일 객체
* @returns {boolean} - 읽음 여부
*/
export const isEmailRead = (email) => {
if (!email) return false;
return email.isRead || (email.flags && email.flags.includes("\\Seen"));
};
/**
* 첨부파일이 있는지 확인
* @param {Object} email - 이메일 객체
* @returns {boolean} - 첨부파일 존재 여부
*/
export const hasAttachments = (email) => {
if (!email) return false;
let atts = email.attachments;
if (typeof atts === "string") {
try {
atts = JSON.parse(atts);
} catch {
atts = [];
}
}
return Array.isArray(atts) && atts.length > 0;
};
/**
* 첨부파일 배열 파싱 (JSON 문자열 처리)
* @param {Array|string} attachments - 첨부파일 데이터
* @returns {Array} - 파싱된 첨부파일 배열
*/
export const parseAttachments = (attachments) => {
if (!attachments) return [];
if (Array.isArray(attachments)) return attachments;
if (typeof attachments === "string") {
try {
const parsed = JSON.parse(attachments);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
/**
* 파일 확장자로 파일 타입 분류
* @param {string} filename - 파일명
* @returns {'image' | 'document' | 'archive' | 'other'} - 파일 타입
*/
export const getFileType = (filename) => {
if (!filename) return "other";
const ext = filename.split(".").pop()?.toLowerCase();
if (
["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"].includes(ext)
) {
return "image";
}
if (
["pdf", "doc", "docx", "txt", "rtf", "xls", "xlsx", "ppt", "pptx"].includes(
ext
)
) {
return "document";
}
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) {
return "archive";
}
return "other";
};
/**
* 비밀번호 강도 검사
* @param {string} password - 비밀번호
* @returns {{ strength: number, message: string }} - 강도 (0-4) 메시지
*/
export const checkPasswordStrength = (password) => {
if (!password) return { strength: 0, message: "비밀번호를 입력하세요" };
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const messages = ["매우 약함", "약함", "보통", "강함", "매우 강함"];
return {
strength: Math.min(strength, 4),
message: messages[Math.min(strength, 4)],
};
};

View file

@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: "#6586c6",
secondary: "#64748b",
},
fontFamily: {
sans: ["Pretendard", "sans-serif"],
},
keyframes: {
"fade-in-down": {
"0%": { opacity: "0", transform: "translateY(-10px)" },
"100%": { opacity: "1", transform: "translateY(0)" },
},
"fade-out-up": {
"0%": { opacity: "1", transform: "translateY(0)" },
"100%": { opacity: "0", transform: "translateY(-10px)" },
},
},
animation: {
"fade-in-down": "fade-in-down 0.2s ease-out",
"fade-out-up": "fade-out-up 0.2s ease-in forwards",
},
},
},
plugins: [],
};

42
frontend/vite.config.js Normal file
View file

@ -0,0 +1,42 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
// 빌드 최적화
build: {
// 청크 크기 경고 제한 증가
chunkSizeWarningLimit: 1500,
// 소스맵 비활성화 (프로덕션)
sourcemap: false,
// 빌드 최적화
minify: "esbuild",
// 청크 분할
rollupOptions: {
output: {
manualChunks: {
vendor: ["react", "react-dom", "react-router-dom"],
ui: ["@mui/material", "@emotion/react", "@emotion/styled"],
},
},
},
},
// 개발 서버 설정 (로컬 개발 시에만 사용)
server: {
host: true,
port: 80,
allowedHosts: ["mailbox.caadiq.co.kr"],
proxy: {
"/api": {
target: "http://backend:3000",
changeOrigin: true,
},
},
},
});

548
package-lock.json generated Normal file
View file

@ -0,0 +1,548 @@
{
"name": "email",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@aws-sdk/s3-request-presigner": "^3.946.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.946.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.946.0.tgz",
"integrity": "sha512-u2BkbLLVbMFrEiXrko2+S6ih5sUZPlbVyRPtXOqMHlCyzr70sE8kIiD6ba223rQeIFPcYfW/wHc6k4ihW2xxVg==",
"dependencies": {
"@aws-sdk/types": "3.936.0",
"@aws-sdk/xml-builder": "3.930.0",
"@smithy/core": "^3.18.7",
"@smithy/node-config-provider": "^4.3.5",
"@smithy/property-provider": "^4.2.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/signature-v4": "^5.3.5",
"@smithy/smithy-client": "^4.9.10",
"@smithy/types": "^4.9.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-middleware": "^4.2.5",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.946.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.946.0.tgz",
"integrity": "sha512-0UTFmFd8PX2k/jLu/DBmR+mmLQWAtUGHYps9Rjx3dcXNwaMLaa/39NoV3qn7Dwzfpqc6JZlZzBk+NDOCJIHW9g==",
"dependencies": {
"@aws-sdk/core": "3.946.0",
"@aws-sdk/types": "3.936.0",
"@aws-sdk/util-arn-parser": "3.893.0",
"@smithy/core": "^3.18.7",
"@smithy/node-config-provider": "^4.3.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/signature-v4": "^5.3.5",
"@smithy/smithy-client": "^4.9.10",
"@smithy/types": "^4.9.0",
"@smithy/util-config-provider": "^4.2.0",
"@smithy/util-middleware": "^4.2.5",
"@smithy/util-stream": "^4.5.6",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.946.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.946.0.tgz",
"integrity": "sha512-NPNCGW84ZEYCKhN+XkY307eumlBq/E0vWU0iYhVVq7i5cjnA2wwLenKyYp/+0FpXvv83MC/jT9BVB5XMNu4RUw==",
"dependencies": {
"@aws-sdk/signature-v4-multi-region": "3.946.0",
"@aws-sdk/types": "3.936.0",
"@aws-sdk/util-format-url": "3.936.0",
"@smithy/middleware-endpoint": "^4.3.14",
"@smithy/protocol-http": "^5.3.5",
"@smithy/smithy-client": "^4.9.10",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.946.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.946.0.tgz",
"integrity": "sha512-61FZ685lKiJuQ06g6U7K3PL9EwKCxNm51wNlxyKV57nnl1GrLD0NC8O3/hDNkCQLNBArT9y3IXl2H7TtIxP8Jg==",
"dependencies": {
"@aws-sdk/middleware-sdk-s3": "3.946.0",
"@aws-sdk/types": "3.936.0",
"@smithy/protocol-http": "^5.3.5",
"@smithy/signature-v4": "^5.3.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.936.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz",
"integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/util-arn-parser": {
"version": "3.893.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz",
"integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/util-format-url": {
"version": "3.936.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.936.0.tgz",
"integrity": "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g==",
"dependencies": {
"@aws-sdk/types": "3.936.0",
"@smithy/querystring-builder": "^4.2.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.930.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz",
"integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==",
"dependencies": {
"@smithy/types": "^4.9.0",
"fast-xml-parser": "5.2.5",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/abort-controller": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz",
"integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/core": {
"version": "3.18.7",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz",
"integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==",
"dependencies": {
"@smithy/middleware-serde": "^4.2.6",
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-middleware": "^4.2.5",
"@smithy/util-stream": "^4.5.6",
"@smithy/util-utf8": "^4.2.0",
"@smithy/uuid": "^1.1.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.3.6",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz",
"integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==",
"dependencies": {
"@smithy/protocol-http": "^5.3.5",
"@smithy/querystring-builder": "^4.2.5",
"@smithy/types": "^4.9.0",
"@smithy/util-base64": "^4.3.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/is-array-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
"integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/middleware-endpoint": {
"version": "4.3.14",
"resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz",
"integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==",
"dependencies": {
"@smithy/core": "^3.18.7",
"@smithy/middleware-serde": "^4.2.6",
"@smithy/node-config-provider": "^4.3.5",
"@smithy/shared-ini-file-loader": "^4.4.0",
"@smithy/types": "^4.9.0",
"@smithy/url-parser": "^4.2.5",
"@smithy/util-middleware": "^4.2.5",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/middleware-serde": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz",
"integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==",
"dependencies": {
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/middleware-stack": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz",
"integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-config-provider": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz",
"integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==",
"dependencies": {
"@smithy/property-provider": "^4.2.5",
"@smithy/shared-ini-file-loader": "^4.4.0",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz",
"integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==",
"dependencies": {
"@smithy/abort-controller": "^4.2.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/querystring-builder": "^4.2.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/property-provider": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz",
"integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/protocol-http": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz",
"integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/querystring-builder": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz",
"integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==",
"dependencies": {
"@smithy/types": "^4.9.0",
"@smithy/util-uri-escape": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/querystring-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz",
"integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/shared-ini-file-loader": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz",
"integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.3.5",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz",
"integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.0",
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"@smithy/util-hex-encoding": "^4.2.0",
"@smithy/util-middleware": "^4.2.5",
"@smithy/util-uri-escape": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/smithy-client": {
"version": "4.9.10",
"resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz",
"integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==",
"dependencies": {
"@smithy/core": "^3.18.7",
"@smithy/middleware-endpoint": "^4.3.14",
"@smithy/middleware-stack": "^4.2.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"@smithy/util-stream": "^4.5.6",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz",
"integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/url-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz",
"integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==",
"dependencies": {
"@smithy/querystring-parser": "^4.2.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-base64": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
"integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-body-length-browser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
"integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-buffer-from": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
"integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
"dependencies": {
"@smithy/is-array-buffer": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-config-provider": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
"integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-hex-encoding": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
"integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-middleware": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz",
"integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==",
"dependencies": {
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-stream": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz",
"integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==",
"dependencies": {
"@smithy/fetch-http-handler": "^5.3.6",
"@smithy/node-http-handler": "^4.4.5",
"@smithy/types": "^4.9.0",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-buffer-from": "^4.2.0",
"@smithy/util-hex-encoding": "^4.2.0",
"@smithy/util-utf8": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-uri-escape": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
"integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/util-utf8": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
"integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
"dependencies": {
"@smithy/util-buffer-from": "^4.2.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/uuid": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
"integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
]
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
}
}
}

View file

@ -0,0 +1,4 @@
# 베이지안 분류기 설정 (Redis 백엔드)
autolearn = true;
backend = "redis";
servers = "redis:6379";

View file

@ -0,0 +1 @@
# rspamd 옵션 설정 local_addrs = "0.0.0.0/0";

View file

@ -0,0 +1,2 @@
# Redis 설정
servers = "redis:6379";

View file

@ -0,0 +1 @@
# rspamd 컨트롤러 워커 설정 bind_socket = "*:11334"; password = "q1";

View file

@ -0,0 +1 @@
# rspamd 워커 설정 # HTTP API를 통해 스팸 검사 및 학습 bind_socket = "*:11333";