Files
sam-manage/resources/views/system/tenant-mail/edit.blade.php
김보곤 a0ba7fc13f feat: [email] 테넌트 이메일 설정 관리 기능 추가
- TenantMailConfigController: 목록, 편집, 저장, SMTP 테스트 API
- TenantMailConfig, MailLog 모델 추가
- SmtpConnectionTester: SMTP 연결 테스트 서비스 (에러 코드, 트러블슈팅)
- TenantMailService: 테넌트 설정 기반 메일 발송 (쿼터, Fallback)
- config/mail-presets.php: Gmail/Naver/MS365 등 8개 SMTP 프리셋
- Blade 뷰: 테넌트 목록 현황 + 설정 폼 (프리셋 자동 채움, 연결 테스트)
- 라우트 추가: /system/tenant-mail/*
2026-03-12 07:42:17 +09:00

510 lines
20 KiB
PHP

@extends('layouts.app')
@section('title', '이메일 설정 — ' . $tenant->company_name)
@push('styles')
<style>
[x-cloak] { display: none !important; }
.form-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 16px;
}
.form-section h3 {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: #4b5563;
margin-bottom: 4px;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.15s;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-input:disabled {
background: #f9fafb;
color: #9ca3af;
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
background: white;
}
.form-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.radio-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
}
.radio-card:hover {
border-color: #93c5fd;
}
.radio-card.active {
border-color: #3b82f6;
background: #eff6ff;
}
.test-result {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-top: 12px;
}
.test-result.success {
background: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.test-result.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.status-info {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
background: #f9fafb;
border: 1px solid #e5e7eb;
}
.preset-note {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
display: flex;
align-items: center;
gap: 4px;
}
</style>
@endpush
@section('content')
<div x-data="tenantMailConfig()" x-cloak class="space-y-4" style="max-width: 800px;">
<!-- 헤더 -->
<div class="flex items-center gap-4">
<a href="{{ route('system.tenant-mail.index') }}"
class="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition">
<i class="ri-arrow-left-line text-xl"></i>
</a>
<div>
<h1 class="text-xl font-bold text-gray-800">이메일 설정</h1>
<p class="text-sm text-gray-500">{{ $tenant->company_name }} (ID: {{ $tenant->id }})</p>
</div>
</div>
<!-- 상태 요약 -->
<div class="flex flex-wrap gap-3">
<div class="status-info">
<i class="ri-mail-send-line text-blue-500"></i>
<span>오늘 발송: <strong>{{ $todayCount }}</strong>/{{ $config?->daily_limit ?? 500 }}</span>
</div>
<div class="status-info">
<i class="ri-calendar-line text-purple-500"></i>
<span>이번 : <strong>{{ $monthCount }}</strong></span>
</div>
@if($config && $config->getOption('connection_test.last_tested_at'))
<div class="status-info">
<i class="ri-check-double-line text-green-500"></i>
<span>마지막 테스트: {{ \Carbon\Carbon::parse($config->getOption('connection_test.last_tested_at'))->format('m/d H:i') }}</span>
</div>
@endif
</div>
<!-- 기본 정보 -->
<div class="form-section">
<h3><i class="ri-user-line text-blue-500"></i> 기본 정보</h3>
<div class="grid gap-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">발신자명 <span class="text-red-500">*</span></label>
<input type="text" x-model="form.from_name" class="form-input" placeholder="회사명 또는 서비스명">
</div>
<div>
<label class="form-label">발신 이메일 <span class="text-red-500">*</span></label>
<input type="email" x-model="form.from_address" class="form-input" placeholder="admin@company.com">
</div>
<div>
<label class="form-label">회신 주소 (선택)</label>
<input type="email" x-model="form.reply_to" class="form-input" placeholder="reply@company.com">
</div>
<div>
<label class="form-label">일일 발송 한도</label>
<input type="number" x-model="form.daily_limit" class="form-input" min="1" max="99999">
</div>
</div>
</div>
<!-- 발송 방식 -->
<div class="form-section">
<h3><i class="ri-mail-settings-line text-purple-500"></i> 발송 방식</h3>
<div class="grid gap-3" style="grid-template-columns: 1fr 1fr;">
<label class="radio-card" :class="{ active: form.provider === 'platform' }"
@click="form.provider = 'platform'">
<input type="radio" x-model="form.provider" value="platform" class="mt-0.5">
<div>
<div class="font-medium text-gray-800">SAM 기본</div>
<div class="text-xs text-gray-500 mt-1">
플랫폼 공용 SMTP로 발송<br>
발신: noreply@sam.codebridge-x.com<br>
Reply-To에 이메일 자동 적용
</div>
</div>
</label>
<label class="radio-card" :class="{ active: form.provider === 'smtp' }"
@click="form.provider = 'smtp'">
<input type="radio" x-model="form.provider" value="smtp" class="mt-0.5">
<div>
<div class="font-medium text-gray-800">자체 SMTP</div>
<div class="text-xs text-gray-500 mt-1">
테넌트 회사 메일 서버로 발송<br>
회사 도메인으로 메일 발신<br>
비밀번호 설정 필요
</div>
</div>
</label>
</div>
</div>
<!-- SMTP 설정 (자체 SMTP 선택 ) -->
<div class="form-section" x-show="form.provider === 'smtp'" x-transition>
<h3><i class="ri-server-line text-orange-500"></i> SMTP 설정</h3>
<!-- 프리셋 선택 -->
<div class="mb-4">
<label class="form-label">메일 서비스 프리셋</label>
<select x-model="form.preset" @change="applyPreset()" class="form-select">
<template x-for="(preset, key) in presets" :key="key">
<option :value="key" x-text="preset.label"></option>
</template>
</select>
<div class="preset-note" x-show="currentPresetNote">
<i class="ri-information-line"></i>
<span x-text="currentPresetNote"></span>
</div>
</div>
<div class="grid gap-4" style="grid-template-columns: 2fr 1fr 1fr;">
<div>
<label class="form-label">SMTP 호스트 <span class="text-red-500">*</span></label>
<input type="text" x-model="form.smtp_host" class="form-input" placeholder="smtp.gmail.com"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24' && presets[form.preset]?.host">
</div>
<div>
<label class="form-label">포트 <span class="text-red-500">*</span></label>
<input type="number" x-model="form.smtp_port" class="form-input" placeholder="587"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24'">
</div>
<div>
<label class="form-label">암호화</label>
<select x-model="form.smtp_encryption" class="form-select"
:disabled="form.preset !== 'custom' && form.preset !== 'cafe24'">
<option value="tls">TLS</option>
<option value="ssl">SSL</option>
</select>
</div>
</div>
<div class="grid gap-4 mt-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">SMTP 사용자명 (이메일) <span class="text-red-500">*</span></label>
<input type="text" x-model="form.smtp_username" class="form-input" placeholder="user@company.com">
</div>
<div>
<label class="form-label">SMTP 비밀번호 ( 비밀번호) <span class="text-red-500">*</span></label>
<input type="password" x-model="form.smtp_password" class="form-input"
placeholder="{{ $config && $config->getSmtpHost() ? '변경하려면 새로 입력' : '앱 비밀번호 입력' }}">
</div>
</div>
<!-- 연결 테스트 -->
<div class="mt-4 flex items-center gap-3">
<button type="button" @click="testConnection()"
:disabled="testing || !form.smtp_host || !form.smtp_username"
class="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:bg-gray-300 text-white text-sm rounded-lg transition inline-flex items-center gap-2">
<template x-if="testing">
<i class="ri-loader-4-line animate-spin"></i>
</template>
<template x-if="!testing">
<i class="ri-link"></i>
</template>
<span x-text="testing ? '테스트 중...' : '연결 테스트'"></span>
</button>
<label class="flex items-center gap-2 text-sm text-gray-600">
<input type="checkbox" x-model="sendTestMail" class="rounded border-gray-300">
테스트 메일 발송
</label>
</div>
<!-- 테스트 결과 -->
<div x-show="testResult" x-transition>
<template x-if="testResult?.ok">
<div class="test-result success">
<div class="flex items-center gap-2 font-medium">
<i class="ri-check-line"></i>
<span x-text="testResult.message"></span>
</div>
<div class="mt-1 text-xs opacity-80">
응답시간: <span x-text="testResult.data?.response_time_ms"></span>ms
<template x-if="testResult.data?.test_mail_sent">
<span> | 테스트 메일 발송 완료</span>
</template>
</div>
</div>
</template>
<template x-if="testResult && !testResult.ok">
<div class="test-result error">
<div class="flex items-center gap-2 font-medium">
<i class="ri-close-line"></i>
<span x-text="testResult.message"></span>
</div>
<div class="mt-1 text-xs opacity-80" x-show="testResult.data?.troubleshoot"
x-text="testResult.data?.troubleshoot"></div>
</div>
</template>
</div>
</div>
<!-- 브랜딩 -->
<div class="form-section">
<h3><i class="ri-palette-line text-pink-500"></i> 브랜딩 (선택)</h3>
<div class="grid gap-4" style="grid-template-columns: 1fr 1fr;">
<div>
<label class="form-label">회사명</label>
<input type="text" x-model="form.branding_company_name" class="form-input"
placeholder="{{ $tenant->company_name }}">
</div>
<div>
<label class="form-label">테마 컬러</label>
<div class="flex items-center gap-2">
<input type="color" x-model="form.branding_primary_color"
class="h-9 w-12 rounded border border-gray-300 cursor-pointer">
<input type="text" x-model="form.branding_primary_color" class="form-input" style="flex:1;"
placeholder="#1a56db">
</div>
</div>
<div>
<label class="form-label">회사 주소</label>
<input type="text" x-model="form.branding_company_address" class="form-input"
placeholder="서울시 강남구...">
</div>
<div>
<label class="form-label">연락처</label>
<input type="text" x-model="form.branding_company_phone" class="form-input"
placeholder="02-1234-5678">
</div>
</div>
<div class="mt-4">
<label class="form-label">푸터 문구</label>
<input type="text" x-model="form.branding_footer_text" class="form-input"
placeholder="SAM 시스템에서 발송된 메일입니다.">
</div>
</div>
<!-- 저장 버튼 -->
<div class="flex items-center justify-between">
<a href="{{ route('system.tenant-mail.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
<i class="ri-arrow-left-line"></i> 목록으로
</a>
<button type="button" @click="save()" :disabled="saving"
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white text-sm font-medium rounded-lg transition inline-flex items-center gap-2">
<template x-if="saving">
<i class="ri-loader-4-line animate-spin"></i>
</template>
<template x-if="!saving">
<i class="ri-save-line"></i>
</template>
<span x-text="saving ? '저장 중...' : '저장'"></span>
</button>
</div>
</div>
@push('scripts')
<script>
function tenantMailConfig() {
const config = @json($config);
const presets = @json($presets);
const tenantId = {{ $tenant->id }};
return {
presets: presets,
testing: false,
saving: false,
sendTestMail: false,
testResult: null,
form: {
provider: config?.provider || 'platform',
from_name: config?.from_name || '{{ $tenant->company_name }}',
from_address: config?.from_address || '',
reply_to: config?.reply_to || '',
daily_limit: config?.daily_limit || 500,
is_active: config?.is_active ?? true,
// SMTP
preset: config?.options?.preset || 'gmail',
smtp_host: config?.options?.smtp?.host || '',
smtp_port: config?.options?.smtp?.port || 587,
smtp_encryption: config?.options?.smtp?.encryption || 'tls',
smtp_username: config?.options?.smtp?.username || '',
smtp_password: '',
// 브랜딩
branding_company_name: config?.options?.branding?.company_name || '',
branding_primary_color: config?.options?.branding?.primary_color || '#1a56db',
branding_company_address: config?.options?.branding?.company_address || '',
branding_company_phone: config?.options?.branding?.company_phone || '',
branding_footer_text: config?.options?.branding?.footer_text || 'SAM 시스템에서 발송된 메일입니다.',
},
get currentPresetNote() {
return this.presets[this.form.preset]?.notes || '';
},
applyPreset() {
const preset = this.presets[this.form.preset];
if (!preset) return;
if (preset.host) {
this.form.smtp_host = preset.host;
}
this.form.smtp_port = preset.port;
this.form.smtp_encryption = preset.encryption;
if (preset.daily_limit) {
this.form.daily_limit = preset.daily_limit;
}
this.testResult = null;
},
async testConnection() {
if (!this.form.smtp_host || !this.form.smtp_username) return;
// 비밀번호 확인: 신규 입력이 없고 기존 설정도 없는 경우
const hasExistingPassword = config?.options?.smtp?.password;
if (!this.form.smtp_password && !hasExistingPassword) {
this.testResult = {
ok: false,
message: 'SMTP 비밀번호를 입력하세요',
data: { troubleshoot: '앱 비밀번호를 입력한 후 테스트하세요.' }
};
return;
}
this.testing = true;
this.testResult = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch(`/system/tenant-mail/${tenantId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify({
host: this.form.smtp_host,
port: parseInt(this.form.smtp_port),
encryption: this.form.smtp_encryption,
username: this.form.smtp_username,
password: this.form.smtp_password || '__EXISTING__',
preset: this.form.preset,
send_test_mail: this.sendTestMail,
}),
});
this.testResult = await response.json();
} catch (e) {
this.testResult = {
ok: false,
message: '테스트 요청 실패: ' + e.message,
data: {}
};
} finally {
this.testing = false;
}
},
async save() {
if (!this.form.from_name || !this.form.from_address) {
alert('발신자명과 발신 이메일은 필수입니다.');
return;
}
if (this.form.provider === 'smtp' && (!this.form.smtp_host || !this.form.smtp_username)) {
alert('SMTP 호스트와 사용자명은 필수입니다.');
return;
}
this.saving = true;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const response = await fetch(`/system/tenant-mail/${tenantId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'Accept': 'application/json',
},
body: JSON.stringify(this.form),
});
const result = await response.json();
if (result.ok) {
alert('메일 설정이 저장되었습니다.');
window.location.reload();
} else {
alert(result.message || '저장 실패');
}
} catch (e) {
alert('저장 요청 실패: ' + e.message);
} finally {
this.saving = false;
}
},
};
}
</script>
@endpush
@endsection