2025-10-01 16:15:53 +09:00
|
|
|
/**
|
|
|
|
|
* 메일 관리 시스템 API 클라이언트
|
|
|
|
|
* 파일 기반 메일 계정 및 템플릿 관리
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
// API 기본 URL
|
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
// ============================================
|
|
|
|
|
// 타입 정의
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
export interface MailAccount {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
smtpHost: string;
|
|
|
|
|
smtpPort: number;
|
|
|
|
|
smtpSecure: boolean;
|
|
|
|
|
smtpUsername: string;
|
|
|
|
|
smtpPassword: string; // 암호화된 상태
|
|
|
|
|
dailyLimit: number;
|
|
|
|
|
status: 'active' | 'inactive';
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateMailAccountDto {
|
|
|
|
|
name: string;
|
|
|
|
|
email: string;
|
|
|
|
|
smtpHost: string;
|
|
|
|
|
smtpPort: number;
|
|
|
|
|
smtpSecure: boolean;
|
|
|
|
|
smtpUsername: string;
|
|
|
|
|
smtpPassword: string;
|
|
|
|
|
dailyLimit?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
|
|
|
|
|
status?: 'active' | 'inactive';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MailComponent {
|
|
|
|
|
id: string;
|
2025-11-28 11:34:48 +09:00
|
|
|
type: 'text' | 'button' | 'image' | 'spacer' | 'header' | 'infoTable' | 'alertBox' | 'divider' | 'footer' | 'numberedList';
|
2025-10-01 16:15:53 +09:00
|
|
|
content?: string;
|
|
|
|
|
text?: string;
|
|
|
|
|
url?: string;
|
|
|
|
|
src?: string;
|
|
|
|
|
height?: number;
|
|
|
|
|
styles?: Record<string, string>;
|
2025-11-28 11:34:48 +09:00
|
|
|
// 헤더 컴포넌트용
|
|
|
|
|
logoSrc?: string;
|
|
|
|
|
brandName?: string;
|
|
|
|
|
sendDate?: string;
|
|
|
|
|
headerBgColor?: string;
|
|
|
|
|
// 정보 테이블용
|
|
|
|
|
rows?: Array<{ label: string; value: string }>;
|
|
|
|
|
tableTitle?: string;
|
|
|
|
|
// 강조 박스용
|
|
|
|
|
alertType?: 'info' | 'warning' | 'danger' | 'success';
|
|
|
|
|
alertTitle?: string;
|
|
|
|
|
// 푸터용
|
|
|
|
|
companyName?: string;
|
|
|
|
|
ceoName?: string;
|
|
|
|
|
businessNumber?: string;
|
|
|
|
|
address?: string;
|
|
|
|
|
phone?: string;
|
|
|
|
|
email?: string;
|
|
|
|
|
copyright?: string;
|
|
|
|
|
// 번호 리스트용
|
|
|
|
|
listItems?: string[];
|
|
|
|
|
listTitle?: string;
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MailTemplate {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
components: MailComponent[];
|
|
|
|
|
category?: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CreateMailTemplateDto {
|
|
|
|
|
name: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
components: MailComponent[];
|
|
|
|
|
category?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
|
|
|
|
|
|
|
|
|
|
export interface SendMailDto {
|
|
|
|
|
accountId: string;
|
|
|
|
|
templateId?: string;
|
2025-10-02 18:22:58 +09:00
|
|
|
to: string[]; // 받는 사람
|
|
|
|
|
cc?: string[]; // 참조 (Carbon Copy)
|
|
|
|
|
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
2025-10-01 16:15:53 +09:00
|
|
|
subject: string;
|
|
|
|
|
variables?: Record<string, string>; // 템플릿 변수 치환
|
|
|
|
|
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
// ============================================
|
|
|
|
|
// 발송 이력 타입
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
export interface AttachmentInfo {
|
|
|
|
|
filename: string;
|
|
|
|
|
originalName: string;
|
|
|
|
|
size: number;
|
|
|
|
|
path: string;
|
|
|
|
|
mimetype: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SentMailHistory {
|
|
|
|
|
id: string;
|
|
|
|
|
accountId: string;
|
|
|
|
|
accountName: string;
|
|
|
|
|
accountEmail: string;
|
|
|
|
|
to: string[];
|
|
|
|
|
cc?: string[];
|
|
|
|
|
bcc?: string[];
|
|
|
|
|
subject: string;
|
|
|
|
|
htmlContent: string;
|
|
|
|
|
templateId?: string;
|
|
|
|
|
templateName?: string;
|
|
|
|
|
attachments?: AttachmentInfo[];
|
|
|
|
|
sentAt: string;
|
2025-10-22 16:06:04 +09:00
|
|
|
status: 'success' | 'failed' | 'draft';
|
2025-10-02 18:22:58 +09:00
|
|
|
messageId?: string;
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
accepted?: string[];
|
|
|
|
|
rejected?: string[];
|
2025-10-22 16:06:04 +09:00
|
|
|
isDraft?: boolean;
|
|
|
|
|
deletedAt?: string;
|
|
|
|
|
updatedAt?: string;
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SentMailListQuery {
|
|
|
|
|
page?: number;
|
|
|
|
|
limit?: number;
|
|
|
|
|
searchTerm?: string;
|
2025-10-22 16:06:04 +09:00
|
|
|
status?: 'success' | 'failed' | 'draft' | 'all';
|
2025-10-02 18:22:58 +09:00
|
|
|
accountId?: string;
|
|
|
|
|
startDate?: string;
|
|
|
|
|
endDate?: string;
|
2025-10-22 16:06:04 +09:00
|
|
|
sortBy?: 'sentAt' | 'subject' | 'updatedAt';
|
2025-10-02 18:22:58 +09:00
|
|
|
sortOrder?: 'asc' | 'desc';
|
2025-10-22 16:06:04 +09:00
|
|
|
includeDeleted?: boolean;
|
|
|
|
|
onlyDeleted?: boolean;
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SentMailListResponse {
|
|
|
|
|
items: SentMailHistory[];
|
|
|
|
|
total: number;
|
|
|
|
|
page: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
totalPages: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MailStatistics {
|
|
|
|
|
totalSent: number;
|
|
|
|
|
successCount: number;
|
|
|
|
|
failedCount: number;
|
|
|
|
|
todayCount: number;
|
|
|
|
|
thisMonthCount: number;
|
|
|
|
|
successRate: number;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
export interface MailSendResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
messageId?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// API 기본 설정
|
|
|
|
|
// ============================================
|
|
|
|
|
|
2025-10-02 14:34:15 +09:00
|
|
|
import { apiClient } from './client';
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
async function fetchApi<T>(
|
|
|
|
|
endpoint: string,
|
2025-10-02 14:34:15 +09:00
|
|
|
options: { method?: string; data?: unknown } = {}
|
2025-10-01 16:15:53 +09:00
|
|
|
): Promise<T> {
|
2025-10-02 14:34:15 +09:00
|
|
|
const { method = 'GET', data } = options;
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-02 14:34:15 +09:00
|
|
|
try {
|
|
|
|
|
const response = await apiClient({
|
2025-10-02 18:22:58 +09:00
|
|
|
url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
|
2025-10-02 14:34:15 +09:00
|
|
|
method,
|
|
|
|
|
data,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 백엔드가 { success: true, data: ... } 형식으로 반환하는 경우 처리
|
|
|
|
|
if (response.data.success && response.data.data !== undefined) {
|
|
|
|
|
return response.data.data as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.data as T;
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (error && typeof error === 'object' && 'response' in error) {
|
|
|
|
|
const axiosError = error as { response?: { data?: { message?: string }; status?: number } };
|
|
|
|
|
throw new Error(axiosError.response?.data?.message || `HTTP ${axiosError.response?.status}`);
|
|
|
|
|
}
|
|
|
|
|
throw new Error('Unknown error');
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 메일 계정 API
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 메일 계정 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailAccounts(): Promise<MailAccount[]> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailAccount[]>('/mail/accounts');
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 메일 계정 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailAccount(id: string): Promise<MailAccount> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailAccount>(`/mail/accounts/${id}`);
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 계정 생성
|
|
|
|
|
*/
|
|
|
|
|
export async function createMailAccount(
|
|
|
|
|
data: CreateMailAccountDto
|
|
|
|
|
): Promise<MailAccount> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailAccount>('/mail/accounts', {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'POST',
|
2025-10-02 14:34:15 +09:00
|
|
|
data,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 계정 수정
|
|
|
|
|
*/
|
|
|
|
|
export async function updateMailAccount(
|
|
|
|
|
id: string,
|
|
|
|
|
data: UpdateMailAccountDto
|
|
|
|
|
): Promise<MailAccount> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailAccount>(`/mail/accounts/${id}`, {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'PUT',
|
2025-10-02 14:34:15 +09:00
|
|
|
data,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 계정 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 15:46:23 +09:00
|
|
|
/**
|
|
|
|
|
* SMTP 연결 테스트
|
|
|
|
|
*/
|
|
|
|
|
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<{ success: boolean; message: string }>(`/mail/accounts/${id}/test-connection`, {
|
2025-10-02 15:46:23 +09:00
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
/**
|
|
|
|
|
* SMTP 연결 테스트
|
|
|
|
|
*/
|
|
|
|
|
export async function testMailConnection(id: string): Promise<{
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
}> {
|
|
|
|
|
return fetchApi<{ success: boolean; message: string }>(
|
2025-10-02 18:22:58 +09:00
|
|
|
`/mail/accounts/${id}/test-connection`,
|
2025-10-01 16:15:53 +09:00
|
|
|
{
|
|
|
|
|
method: 'POST',
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 메일 템플릿 API
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 전체 메일 템플릿 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailTemplate[]>('/mail/templates-file');
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 메일 템플릿 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`);
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 템플릿 생성
|
|
|
|
|
*/
|
|
|
|
|
export async function createMailTemplate(
|
|
|
|
|
data: CreateMailTemplateDto
|
|
|
|
|
): Promise<MailTemplate> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailTemplate>('/mail/templates-file', {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'POST',
|
2025-10-02 14:34:15 +09:00
|
|
|
data,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 템플릿 수정
|
|
|
|
|
*/
|
|
|
|
|
export async function updateMailTemplate(
|
|
|
|
|
id: string,
|
|
|
|
|
data: UpdateMailTemplateDto
|
|
|
|
|
): Promise<MailTemplate> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`, {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'PUT',
|
2025-10-02 14:34:15 +09:00
|
|
|
data,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 템플릿 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<{ success: boolean }>(`/mail/templates-file/${id}`, {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 템플릿 미리보기 (샘플 데이터)
|
|
|
|
|
*/
|
|
|
|
|
export async function previewMailTemplate(
|
|
|
|
|
id: string,
|
|
|
|
|
sampleData?: Record<string, string>
|
|
|
|
|
): Promise<{ html: string }> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'POST',
|
2025-10-02 14:34:15 +09:00
|
|
|
data: { sampleData },
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 메일 발송 API (간단한 버전 - 쿼리 제외)
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 발송 (단건 또는 소규모 발송)
|
|
|
|
|
*/
|
|
|
|
|
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
2025-10-02 18:22:58 +09:00
|
|
|
return fetchApi<MailSendResult>('/mail/send/simple', {
|
2025-10-01 16:15:53 +09:00
|
|
|
method: 'POST',
|
2025-10-02 14:34:15 +09:00
|
|
|
data,
|
2025-10-01 16:15:53 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
/**
|
|
|
|
|
* 대량 메일 발송
|
|
|
|
|
*/
|
|
|
|
|
export interface BulkSendRequest {
|
|
|
|
|
accountId: string;
|
2025-10-22 17:07:38 +09:00
|
|
|
templateId?: string; // 템플릿 ID (선택)
|
|
|
|
|
customHtml?: string; // 직접 작성한 HTML (선택)
|
2025-10-22 16:06:04 +09:00
|
|
|
subject: string;
|
|
|
|
|
recipients: Array<{
|
|
|
|
|
email: string;
|
2025-10-22 17:07:38 +09:00
|
|
|
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
|
2025-10-22 16:06:04 +09:00
|
|
|
}>;
|
|
|
|
|
onProgress?: (sent: number, total: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface BulkSendResult {
|
|
|
|
|
total: number;
|
|
|
|
|
success: number;
|
|
|
|
|
failed: number;
|
|
|
|
|
results: Array<{
|
|
|
|
|
email: string;
|
|
|
|
|
success: boolean;
|
|
|
|
|
messageId?: string;
|
|
|
|
|
error?: string;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
|
|
|
|
|
const { onProgress, ...data } = request;
|
|
|
|
|
|
|
|
|
|
// 프로그레스 콜백이 있으면 시뮬레이션 (실제로는 서버에서 스트리밍 필요)
|
|
|
|
|
if (onProgress) {
|
|
|
|
|
onProgress(0, data.recipients.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await fetchApi<BulkSendResult>('/mail/send/bulk', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
data,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (onProgress) {
|
|
|
|
|
onProgress(result.success, result.total);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
/**
|
|
|
|
|
* 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
|
|
|
|
|
*/
|
|
|
|
|
export function extractTemplateVariables(template: MailTemplate): string[] {
|
|
|
|
|
const variableRegex = /\{(\w+)\}/g;
|
|
|
|
|
const variables = new Set<string>();
|
|
|
|
|
|
|
|
|
|
// subject에서 추출
|
|
|
|
|
const subjectMatches = template.subject.matchAll(variableRegex);
|
|
|
|
|
for (const match of subjectMatches) {
|
|
|
|
|
variables.add(match[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 컴포넌트 content에서 추출
|
|
|
|
|
template.components.forEach((component) => {
|
|
|
|
|
if (component.content) {
|
|
|
|
|
const contentMatches = component.content.matchAll(variableRegex);
|
|
|
|
|
for (const match of contentMatches) {
|
|
|
|
|
variables.add(match[1]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (component.text) {
|
|
|
|
|
const textMatches = component.text.matchAll(variableRegex);
|
|
|
|
|
for (const match of textMatches) {
|
|
|
|
|
variables.add(match[1]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from(variables);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿을 HTML로 렌더링 (프론트엔드 미리보기용)
|
|
|
|
|
*/
|
|
|
|
|
export function renderTemplateToHtml(
|
|
|
|
|
template: MailTemplate,
|
|
|
|
|
variables?: Record<string, string>
|
|
|
|
|
): string {
|
|
|
|
|
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
|
|
|
|
|
|
|
|
|
template.components.forEach((component) => {
|
|
|
|
|
switch (component.type) {
|
|
|
|
|
case 'text':
|
|
|
|
|
let content = component.content || '';
|
|
|
|
|
if (variables) {
|
|
|
|
|
Object.entries(variables).forEach(([key, value]) => {
|
|
|
|
|
content = content.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
html += `<div style="${styleObjectToString(component.styles)}">${content}</div>`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'button':
|
|
|
|
|
let buttonText = component.text || 'Button';
|
|
|
|
|
if (variables) {
|
|
|
|
|
Object.entries(variables).forEach(([key, value]) => {
|
|
|
|
|
buttonText = buttonText.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
html += `
|
|
|
|
|
<a href="${component.url || '#'}" style="
|
|
|
|
|
display: inline-block;
|
|
|
|
|
padding: 12px 24px;
|
|
|
|
|
background-color: #007bff;
|
|
|
|
|
color: white;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
${styleObjectToString(component.styles)}
|
|
|
|
|
">${buttonText}</a>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'image':
|
|
|
|
|
html += `<img src="${component.src || ''}" style="max-width: 100%; ${styleObjectToString(component.styles)}" />`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'spacer':
|
|
|
|
|
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
|
|
|
|
break;
|
2025-11-28 11:34:48 +09:00
|
|
|
|
|
|
|
|
case 'header':
|
|
|
|
|
html += `
|
|
|
|
|
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
|
|
|
|
<table style="width: 100%;">
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="vertical-align: middle;">
|
|
|
|
|
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
|
|
|
|
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
|
|
|
|
${component.sendDate || ''}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'infoTable':
|
|
|
|
|
html += `
|
|
|
|
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
|
|
|
|
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
|
|
|
${(component.rows || []).map((row, i) => `
|
|
|
|
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
|
|
|
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
|
|
|
|
<td style="padding: 12px 16px;">${row.value}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`).join('')}
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'alertBox':
|
|
|
|
|
const alertColors = {
|
|
|
|
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
|
|
|
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
|
|
|
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
|
|
|
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
|
|
|
|
};
|
|
|
|
|
const colors = alertColors[component.alertType || 'info'];
|
|
|
|
|
html += `
|
|
|
|
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
|
|
|
|
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
|
|
|
|
<div>${component.content || ''}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'divider':
|
|
|
|
|
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'footer':
|
|
|
|
|
html += `
|
|
|
|
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
|
|
|
|
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
|
|
|
|
${(component.ceoName || component.businessNumber) ? `
|
|
|
|
|
<div style="margin-bottom: 4px;">
|
|
|
|
|
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
|
|
|
|
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
|
|
|
|
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
` : ''}
|
|
|
|
|
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
|
|
|
|
${(component.phone || component.email) ? `
|
|
|
|
|
<div style="margin-bottom: 4px;">
|
|
|
|
|
${component.phone ? `Tel: ${component.phone}` : ''}
|
|
|
|
|
${component.phone && component.email ? ' | ' : ''}
|
|
|
|
|
${component.email ? `Email: ${component.email}` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
` : ''}
|
|
|
|
|
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'numberedList':
|
|
|
|
|
html += `
|
|
|
|
|
<div style="padding: 16px; ${styleObjectToString(component.styles)}">
|
|
|
|
|
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
|
|
|
|
<ol style="margin: 0; padding-left: 20px;">
|
|
|
|
|
${(component.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
|
|
|
|
</ol>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
break;
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
html += '</div>';
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function styleObjectToString(styles?: Record<string, string>): string {
|
|
|
|
|
if (!styles) return '';
|
|
|
|
|
return Object.entries(styles)
|
|
|
|
|
.map(([key, value]) => `${camelToKebab(key)}: ${value}`)
|
|
|
|
|
.join('; ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function camelToKebab(str: string): string {
|
|
|
|
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
// ============================================
|
|
|
|
|
// 📥 메일 수신 API (Step 2)
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
export interface ReceivedMail {
|
|
|
|
|
id: string;
|
|
|
|
|
messageId: string;
|
|
|
|
|
from: string;
|
|
|
|
|
to: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
date: string;
|
|
|
|
|
preview: string;
|
|
|
|
|
isRead: boolean;
|
|
|
|
|
hasAttachments: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MailDetail extends ReceivedMail {
|
|
|
|
|
htmlBody: string;
|
|
|
|
|
textBody: string;
|
|
|
|
|
cc?: string;
|
|
|
|
|
bcc?: string;
|
|
|
|
|
attachments: Array<{
|
|
|
|
|
filename: string;
|
|
|
|
|
contentType: string;
|
|
|
|
|
size: number;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 받은 메일 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getReceivedMails(
|
|
|
|
|
accountId: string,
|
|
|
|
|
limit: number = 50
|
|
|
|
|
): Promise<ReceivedMail[]> {
|
|
|
|
|
return fetchApi<ReceivedMail[]>(`/mail/receive/${accountId}?limit=${limit}`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
/**
|
|
|
|
|
* 오늘 수신한 메일 수 조회 (통계)
|
|
|
|
|
*/
|
|
|
|
|
export async function getTodayReceivedCount(accountId?: string): Promise<number> {
|
|
|
|
|
const params = accountId ? `?accountId=${accountId}` : '';
|
|
|
|
|
const response = await fetchApi<{ count: number }>(`/mail/receive/today-count${params}`);
|
|
|
|
|
return response.count;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
/**
|
|
|
|
|
* 메일 상세 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailDetail(
|
|
|
|
|
accountId: string,
|
|
|
|
|
seqno: number
|
|
|
|
|
): Promise<MailDetail> {
|
|
|
|
|
return fetchApi<MailDetail>(`/mail/receive/${accountId}/${seqno}`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
/**
|
|
|
|
|
* 메일 첨부파일 다운로드
|
|
|
|
|
*/
|
|
|
|
|
export async function downloadMailAttachment(
|
|
|
|
|
accountId: string,
|
|
|
|
|
seqno: number,
|
|
|
|
|
attachmentIndex: number
|
|
|
|
|
): Promise<Blob> {
|
|
|
|
|
const token = localStorage.getItem('authToken');
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${API_BASE_URL}/mail/receive/${accountId}/${seqno}/attachment/${attachmentIndex}`,
|
|
|
|
|
{
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`첨부파일 다운로드 실패: ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.blob();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
/**
|
|
|
|
|
* 메일을 읽음으로 표시
|
|
|
|
|
*/
|
|
|
|
|
export async function markMailAsRead(
|
|
|
|
|
accountId: string,
|
|
|
|
|
seqno: number
|
|
|
|
|
): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
return fetchApi(`/mail/receive/${accountId}/${seqno}/mark-read`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* IMAP 연결 테스트
|
|
|
|
|
*/
|
|
|
|
|
export async function testImapConnection(
|
|
|
|
|
accountId: string
|
|
|
|
|
): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
return fetchApi(`/mail/receive/${accountId}/test-imap`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
// 발송 이력 API
|
|
|
|
|
// ============================================
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 발송 이력 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getSentMailList(
|
|
|
|
|
query: SentMailListQuery = {}
|
|
|
|
|
): Promise<SentMailListResponse> {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
|
|
|
|
if (query.page) params.append('page', query.page.toString());
|
|
|
|
|
if (query.limit) params.append('limit', query.limit.toString());
|
|
|
|
|
if (query.searchTerm) params.append('searchTerm', query.searchTerm);
|
|
|
|
|
if (query.status && query.status !== 'all') params.append('status', query.status);
|
|
|
|
|
if (query.accountId) params.append('accountId', query.accountId);
|
|
|
|
|
if (query.startDate) params.append('startDate', query.startDate);
|
|
|
|
|
if (query.endDate) params.append('endDate', query.endDate);
|
|
|
|
|
if (query.sortBy) params.append('sortBy', query.sortBy);
|
|
|
|
|
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
2025-10-22 16:06:04 +09:00
|
|
|
if (query.includeDeleted) params.append('includeDeleted', 'true');
|
|
|
|
|
if (query.onlyDeleted) params.append('onlyDeleted', 'true');
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
return fetchApi(`/mail/sent?${params.toString()}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 발송 이력 상세 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getSentMailById(id: string): Promise<SentMailHistory> {
|
|
|
|
|
return fetchApi(`/mail/sent/${id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-22 16:06:04 +09:00
|
|
|
* 발송 이력 삭제 (Soft Delete)
|
2025-10-02 18:22:58 +09:00
|
|
|
*/
|
|
|
|
|
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
return fetchApi(`/mail/sent/${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
/**
|
|
|
|
|
* 임시 저장 (Draft)
|
|
|
|
|
*/
|
|
|
|
|
export async function saveDraft(data: Partial<SentMailHistory> & { accountId: string }): Promise<SentMailHistory> {
|
|
|
|
|
const response = await apiClient.post('/mail/sent/draft', data);
|
|
|
|
|
return response.data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 임시 저장 업데이트
|
|
|
|
|
*/
|
|
|
|
|
export async function updateDraft(id: string, data: Partial<SentMailHistory>): Promise<SentMailHistory> {
|
|
|
|
|
const response = await apiClient.put(`/mail/sent/draft/${id}`, data);
|
|
|
|
|
return response.data.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 복구
|
|
|
|
|
*/
|
|
|
|
|
export async function restoreMail(id: string): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
return fetchApi(`/mail/sent/${id}/restore`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 메일 영구 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function permanentlyDeleteMail(id: string): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
return fetchApi(`/mail/sent/${id}/permanent`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일괄 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function bulkDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
|
|
|
return fetchApi('/mail/sent/bulk/delete', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ ids }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일괄 영구 삭제
|
|
|
|
|
*/
|
|
|
|
|
export async function bulkPermanentlyDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
|
|
|
return fetchApi('/mail/sent/bulk/permanent-delete', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ ids }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 일괄 복구
|
|
|
|
|
*/
|
|
|
|
|
export async function bulkRestoreMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
|
|
|
return fetchApi('/mail/sent/bulk/restore', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ ids }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 18:22:58 +09:00
|
|
|
/**
|
|
|
|
|
* 메일 발송 통계 조회
|
|
|
|
|
*/
|
|
|
|
|
export async function getMailStatistics(accountId?: string): Promise<MailStatistics> {
|
|
|
|
|
const params = accountId ? `?accountId=${accountId}` : '';
|
|
|
|
|
return fetchApi(`/mail/sent/statistics${params}`);
|
|
|
|
|
}
|