- TenantMailConfigController: 목록, 편집, 저장, SMTP 테스트 API - TenantMailConfig, MailLog 모델 추가 - SmtpConnectionTester: SMTP 연결 테스트 서비스 (에러 코드, 트러블슈팅) - TenantMailService: 테넌트 설정 기반 메일 발송 (쿼터, Fallback) - config/mail-presets.php: Gmail/Naver/MS365 등 8개 SMTP 프리셋 - Blade 뷰: 테넌트 목록 현황 + 설정 폼 (프리셋 자동 채움, 연결 테스트) - 라우트 추가: /system/tenant-mail/*
510 lines
20 KiB
PHP
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
|