362 lines
8.8 KiB
TypeScript
362 lines
8.8 KiB
TypeScript
|
|
/**
|
||
|
|
* 메일 관리 시스템 API 클라이언트
|
||
|
|
* 파일 기반 메일 계정 및 템플릿 관리
|
||
|
|
*/
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 타입 정의
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
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;
|
||
|
|
type: 'text' | 'button' | 'image' | 'spacer';
|
||
|
|
content?: string;
|
||
|
|
text?: string;
|
||
|
|
url?: string;
|
||
|
|
src?: string;
|
||
|
|
height?: number;
|
||
|
|
styles?: Record<string, string>;
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
to: string[]; // 수신자 이메일 배열
|
||
|
|
subject: string;
|
||
|
|
variables?: Record<string, string>; // 템플릿 변수 치환
|
||
|
|
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MailSendResult {
|
||
|
|
success: boolean;
|
||
|
|
messageId?: string;
|
||
|
|
error?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// API 기본 설정
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
const API_BASE_URL = '/api/mail';
|
||
|
|
|
||
|
|
async function fetchApi<T>(
|
||
|
|
endpoint: string,
|
||
|
|
options: RequestInit = {}
|
||
|
|
): Promise<T> {
|
||
|
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||
|
|
...options,
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
...options.headers,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
|
||
|
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await response.json();
|
||
|
|
|
||
|
|
// 백엔드가 { success: true, data: ... } 형식으로 반환하는 경우 처리
|
||
|
|
if (result.success && result.data !== undefined) {
|
||
|
|
return result.data as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
return result as T;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 메일 계정 API
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 전체 메일 계정 목록 조회
|
||
|
|
*/
|
||
|
|
export async function getMailAccounts(): Promise<MailAccount[]> {
|
||
|
|
return fetchApi<MailAccount[]>('/accounts');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 메일 계정 조회
|
||
|
|
*/
|
||
|
|
export async function getMailAccount(id: string): Promise<MailAccount> {
|
||
|
|
return fetchApi<MailAccount>(`/accounts/${id}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 계정 생성
|
||
|
|
*/
|
||
|
|
export async function createMailAccount(
|
||
|
|
data: CreateMailAccountDto
|
||
|
|
): Promise<MailAccount> {
|
||
|
|
return fetchApi<MailAccount>('/accounts', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(data),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 계정 수정
|
||
|
|
*/
|
||
|
|
export async function updateMailAccount(
|
||
|
|
id: string,
|
||
|
|
data: UpdateMailAccountDto
|
||
|
|
): Promise<MailAccount> {
|
||
|
|
return fetchApi<MailAccount>(`/accounts/${id}`, {
|
||
|
|
method: 'PUT',
|
||
|
|
body: JSON.stringify(data),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 계정 삭제
|
||
|
|
*/
|
||
|
|
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
||
|
|
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
|
||
|
|
method: 'DELETE',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* SMTP 연결 테스트
|
||
|
|
*/
|
||
|
|
export async function testMailConnection(id: string): Promise<{
|
||
|
|
success: boolean;
|
||
|
|
message: string;
|
||
|
|
}> {
|
||
|
|
return fetchApi<{ success: boolean; message: string }>(
|
||
|
|
`/accounts/${id}/test-connection`,
|
||
|
|
{
|
||
|
|
method: 'POST',
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 메일 템플릿 API
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 전체 메일 템플릿 목록 조회
|
||
|
|
*/
|
||
|
|
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
||
|
|
return fetchApi<MailTemplate[]>('/templates-file');
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 특정 메일 템플릿 조회
|
||
|
|
*/
|
||
|
|
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||
|
|
return fetchApi<MailTemplate>(`/templates-file/${id}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 템플릿 생성
|
||
|
|
*/
|
||
|
|
export async function createMailTemplate(
|
||
|
|
data: CreateMailTemplateDto
|
||
|
|
): Promise<MailTemplate> {
|
||
|
|
return fetchApi<MailTemplate>('/templates-file', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(data),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 템플릿 수정
|
||
|
|
*/
|
||
|
|
export async function updateMailTemplate(
|
||
|
|
id: string,
|
||
|
|
data: UpdateMailTemplateDto
|
||
|
|
): Promise<MailTemplate> {
|
||
|
|
return fetchApi<MailTemplate>(`/templates-file/${id}`, {
|
||
|
|
method: 'PUT',
|
||
|
|
body: JSON.stringify(data),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 템플릿 삭제
|
||
|
|
*/
|
||
|
|
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
||
|
|
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, {
|
||
|
|
method: 'DELETE',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 템플릿 미리보기 (샘플 데이터)
|
||
|
|
*/
|
||
|
|
export async function previewMailTemplate(
|
||
|
|
id: string,
|
||
|
|
sampleData?: Record<string, string>
|
||
|
|
): Promise<{ html: string }> {
|
||
|
|
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({ sampleData }),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 메일 발송 API (간단한 버전 - 쿼리 제외)
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 메일 발송 (단건 또는 소규모 발송)
|
||
|
|
*/
|
||
|
|
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||
|
|
return fetchApi<MailSendResult>('/send/simple', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(data),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
|
||
|
|
*/
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|