Initial commit: mailbox
This commit is contained in:
commit
c1da47f057
93 changed files with 21844 additions and 0 deletions
16
.env
Normal file
16
.env
Normal 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
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
31
backend/config/database.js
Normal file
31
backend/config/database.js
Normal 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
51
backend/index.js
Normal 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();
|
||||
33
backend/middleware/auth.js
Normal file
33
backend/middleware/auth.js
Normal 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
29
backend/models/Draft.js
Normal 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;
|
||||
60
backend/models/EmailTranslation.js
Normal file
60
backend/models/EmailTranslation.js
Normal 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;
|
||||
77
backend/models/GeminiModel.js
Normal file
77
backend/models/GeminiModel.js
Normal 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;
|
||||
30
backend/models/Important.js
Normal file
30
backend/models/Important.js
Normal 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
32
backend/models/Inbox.js
Normal 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
29
backend/models/Sent.js
Normal 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
48
backend/models/SentLog.js
Normal 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
73
backend/models/SmtpLog.js
Normal 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
33
backend/models/Spam.js
Normal 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;
|
||||
41
backend/models/SystemConfig.js
Normal file
41
backend/models/SystemConfig.js
Normal 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
30
backend/models/Trash.js
Normal 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
42
backend/models/User.js
Normal 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
25
backend/package.json
Normal 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
746
backend/routes/admin.js
Normal 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
111
backend/routes/auth.js
Normal 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
1089
backend/routes/mail.js
Normal file
File diff suppressed because it is too large
Load diff
108
backend/services/emailService.js
Normal file
108
backend/services/emailService.js
Normal 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;
|
||||
}
|
||||
};
|
||||
139
backend/services/geminiService.js
Normal file
139
backend/services/geminiService.js
Normal 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,
|
||||
};
|
||||
150
backend/services/imapService.js
Normal file
150
backend/services/imapService.js
Normal 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,
|
||||
};
|
||||
174
backend/services/rspamdService.js
Normal file
174
backend/services/rspamdService.js
Normal 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,
|
||||
};
|
||||
80
backend/services/s3Service.js
Normal file
80
backend/services/s3Service.js
Normal 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,
|
||||
};
|
||||
};
|
||||
331
backend/services/smtpService.js
Normal file
331
backend/services/smtpService.js
Normal 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);
|
||||
});
|
||||
};
|
||||
48
backend/services/sseService.js
Normal file
48
backend/services/sseService.js
Normal 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
67
backend/utils/dbInit.js
Normal 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;
|
||||
36
backend/utils/emailUtils.js
Normal file
36
backend/utils/emailUtils.js
Normal 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
204
backend/utils/helpers.js
Normal 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
60
docker-compose.yml
Normal 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
25
frontend/Dockerfile
Normal 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
794
frontend/dist/assets/index-4CluA2BD.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-5J-UkslY.css
vendored
Normal file
1
frontend/dist/assets/index-5J-UkslY.css
vendored
Normal file
File diff suppressed because one or more lines are too long
20
frontend/dist/index.html
vendored
Normal file
20
frontend/dist/index.html
vendored
Normal 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
19
frontend/index.html
Normal 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
39
frontend/nginx.conf
Normal 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
6259
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
14
frontend/public/favicon.svg
Normal file
14
frontend/public/favicon.svg
Normal 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
87
frontend/src/App.jsx
Normal 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
|
||||
875
frontend/src/components/ComposeModal.jsx
Normal file
875
frontend/src/components/ComposeModal.jsx
Normal 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;
|
||||
107
frontend/src/components/ConfirmDialog.jsx
Normal file
107
frontend/src/components/ConfirmDialog.jsx
Normal 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;
|
||||
176
frontend/src/components/Dashboard.jsx
Normal file
176
frontend/src/components/Dashboard.jsx
Normal 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;
|
||||
671
frontend/src/components/Header.jsx
Normal file
671
frontend/src/components/Header.jsx
Normal 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;
|
||||
173
frontend/src/components/Login.jsx
Normal file
173
frontend/src/components/Login.jsx
Normal 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;
|
||||
738
frontend/src/components/MailDetail.jsx
Normal file
738
frontend/src/components/MailDetail.jsx
Normal 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;
|
||||
737
frontend/src/components/MailList.jsx
Normal file
737
frontend/src/components/MailList.jsx
Normal 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(/ /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;
|
||||
171
frontend/src/components/Sidebar.jsx
Normal file
171
frontend/src/components/Sidebar.jsx
Normal 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;
|
||||
660
frontend/src/components/admin/AdminDashboard.jsx
Normal file
660
frontend/src/components/admin/AdminDashboard.jsx
Normal 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;
|
||||
143
frontend/src/components/admin/AdminLayout.jsx
Normal file
143
frontend/src/components/admin/AdminLayout.jsx
Normal 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;
|
||||
77
frontend/src/components/admin/AdminPage.jsx
Normal file
77
frontend/src/components/admin/AdminPage.jsx
Normal 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;
|
||||
|
||||
545
frontend/src/components/admin/AdminSettings.jsx
Normal file
545
frontend/src/components/admin/AdminSettings.jsx
Normal 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;
|
||||
530
frontend/src/components/admin/UserManagement.jsx
Normal file
530
frontend/src/components/admin/UserManagement.jsx
Normal 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;
|
||||
44
frontend/src/components/admin/dashboard/PeriodTabs.jsx
Normal file
44
frontend/src/components/admin/dashboard/PeriodTabs.jsx
Normal 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;
|
||||
71
frontend/src/components/admin/dashboard/StatCards.jsx
Normal file
71
frontend/src/components/admin/dashboard/StatCards.jsx
Normal 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;
|
||||
76
frontend/src/components/admin/dashboard/SystemStatus.jsx
Normal file
76
frontend/src/components/admin/dashboard/SystemStatus.jsx
Normal 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;
|
||||
6
frontend/src/components/admin/dashboard/index.js
Normal file
6
frontend/src/components/admin/dashboard/index.js
Normal 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";
|
||||
35
frontend/src/components/compose/AttachmentList.jsx
Normal file
35
frontend/src/components/compose/AttachmentList.jsx
Normal 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;
|
||||
45
frontend/src/components/compose/FormatToolbar.jsx
Normal file
45
frontend/src/components/compose/FormatToolbar.jsx
Normal 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;
|
||||
77
frontend/src/components/compose/LinkDialog.jsx
Normal file
77
frontend/src/components/compose/LinkDialog.jsx
Normal 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;
|
||||
188
frontend/src/components/compose/RecipientInput.jsx
Normal file
188
frontend/src/components/compose/RecipientInput.jsx
Normal 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;
|
||||
7
frontend/src/components/compose/index.js
Normal file
7
frontend/src/components/compose/index.js
Normal 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";
|
||||
137
frontend/src/components/datepicker-custom.css
Normal file
137
frontend/src/components/datepicker-custom.css
Normal 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;
|
||||
}
|
||||
170
frontend/src/components/mail/MailListItem.jsx
Normal file
170
frontend/src/components/mail/MailListItem.jsx
Normal 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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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;
|
||||
247
frontend/src/components/mail/MailListToolbar.jsx
Normal file
247
frontend/src/components/mail/MailListToolbar.jsx
Normal 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;
|
||||
5
frontend/src/components/mail/index.js
Normal file
5
frontend/src/components/mail/index.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* mail 컴포넌트 인덱스
|
||||
*/
|
||||
export { default as MailListToolbar } from "./MailListToolbar";
|
||||
export { default as MailListItem } from "./MailListItem";
|
||||
1196
frontend/src/context/MailContext.jsx
Normal file
1196
frontend/src/context/MailContext.jsx
Normal file
File diff suppressed because it is too large
Load diff
10
frontend/src/hooks/index.js
Normal file
10
frontend/src/hooks/index.js
Normal 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";
|
||||
218
frontend/src/hooks/useAdmin.js
Normal file
218
frontend/src/hooks/useAdmin.js
Normal 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;
|
||||
162
frontend/src/hooks/useAuth.js
Normal file
162
frontend/src/hooks/useAuth.js
Normal 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;
|
||||
542
frontend/src/hooks/useEmailActions.js
Normal file
542
frontend/src/hooks/useEmailActions.js
Normal 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;
|
||||
221
frontend/src/hooks/useEmails.js
Normal file
221
frontend/src/hooks/useEmails.js
Normal 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;
|
||||
156
frontend/src/hooks/useSSE.js
Normal file
156
frontend/src/hooks/useSSE.js
Normal 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
128
frontend/src/index.css
Normal 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
10
frontend/src/main.jsx
Normal 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>,
|
||||
)
|
||||
93
frontend/src/utils/constants.js
Normal file
93
frontend/src/utils/constants.js
Normal 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",
|
||||
};
|
||||
21
frontend/src/utils/decodeHtmlEntities.js
Normal file
21
frontend/src/utils/decodeHtmlEntities.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* HTML 엔티티 디코딩 유틸리티
|
||||
* > < & " ' 등의 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;
|
||||
81
frontend/src/utils/emailIdEncoder.js
Normal file
81
frontend/src/utils/emailIdEncoder.js
Normal 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;
|
||||
}
|
||||
};
|
||||
132
frontend/src/utils/formatters.js
Normal file
132
frontend/src/utils/formatters.js
Normal 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(/ /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);
|
||||
};
|
||||
48
frontend/src/utils/highlightText.jsx
Normal file
48
frontend/src/utils/highlightText.jsx
Normal 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;
|
||||
147
frontend/src/utils/validators.js
Normal file
147
frontend/src/utils/validators.js
Normal 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)],
|
||||
};
|
||||
};
|
||||
30
frontend/tailwind.config.js
Normal file
30
frontend/tailwind.config.js
Normal 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
42
frontend/vite.config.js
Normal 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
548
package-lock.json
generated
Normal 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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
4
rspamd/local.d/classifier-bayes.conf
Normal file
4
rspamd/local.d/classifier-bayes.conf
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# 베이지안 분류기 설정 (Redis 백엔드)
|
||||
autolearn = true;
|
||||
backend = "redis";
|
||||
servers = "redis:6379";
|
||||
1
rspamd/local.d/options.inc
Normal file
1
rspamd/local.d/options.inc
Normal file
|
|
@ -0,0 +1 @@
|
|||
# rspamd 옵션 설정 local_addrs = "0.0.0.0/0";
|
||||
2
rspamd/local.d/redis.conf
Normal file
2
rspamd/local.d/redis.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Redis 설정
|
||||
servers = "redis:6379";
|
||||
1
rspamd/local.d/worker-controller.inc
Normal file
1
rspamd/local.d/worker-controller.inc
Normal file
|
|
@ -0,0 +1 @@
|
|||
# rspamd 컨트롤러 워커 설정 bind_socket = "*:11334"; password = "q1";
|
||||
1
rspamd/local.d/worker-normal.inc
Normal file
1
rspamd/local.d/worker-normal.inc
Normal file
|
|
@ -0,0 +1 @@
|
|||
# rspamd 워커 설정 # HTTP API를 통해 스팸 검사 및 학습 bind_socket = "*:11333";
|
||||
Loading…
Add table
Reference in a new issue