Merge pull request 'feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)' (#147) from feature/ISSUE-140-email-notification into develop

This commit is contained in:
HYOJIN 2026-04-03 10:36:41 +09:00
커밋 66716753de
15개의 변경된 파일393개의 추가작업 그리고 171개의 파일을 삭제

파일 보기

@ -15,6 +15,14 @@
- Bypass API 카탈로그 Swagger 딥링크 연동 (#142)
- 카탈로그 테스트 버튼 클릭 시 해당 API로 Swagger UI 딥링크 이동
- Swagger UI deep-linking 활성화
- Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
- 이메일 공통 모듈 (EmailService, Thymeleaf HTML 템플릿)
- 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송
- 재심사 기능 (REJECTED → PENDING)
### 변경
- UI 텍스트 리레이블링: S&P Bypass → S&P Global API
- 신청 폼 전화번호 필드 제거 및 레이아웃 개선
## [2026-04-02]

파일 보기

@ -135,4 +135,6 @@ export const bypassAccountApi = {
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reject`, data),
reopenRequest: (id: number) =>
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reopen`, {}),
};

파일 보기

@ -38,11 +38,11 @@ const MENU_STRUCTURE: MenuSection[] = [
},
{
id: 'bypass',
label: 'S&P Bypass',
shortLabel: 'Bypass',
label: 'S&P Global API',
shortLabel: 'S&P Global API',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
defaultPath: '/bypass-catalog',
@ -51,7 +51,7 @@ const MENU_STRUCTURE: MenuSection[] = [
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
{ id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' },
{ id: 'bypass-account-management', label: '계정 관리', path: '/bypass-account-management' },
{ id: 'bypass-access-request', label: 'API 접근 신청', path: '/bypass-access-request' },
{ id: 'bypass-access-request', label: 'API 계정 신청', path: '/bypass-access-request' },
],
},
{

파일 보기

@ -11,21 +11,18 @@ interface FormState {
organization: string;
purpose: string;
email: string;
phone: string;
requestedAccessPeriod: string;
}
interface ErrorState {
applicantName?: string;
email?: string;
phone?: string;
requestedAccessPeriod?: string;
}
interface TouchedState {
applicantName: boolean;
email: boolean;
phone: boolean;
requestedAccessPeriod: boolean;
}
@ -36,26 +33,16 @@ const INITIAL_FORM: FormState = {
organization: '',
purpose: '',
email: '',
phone: '',
requestedAccessPeriod: '',
};
const INITIAL_TOUCHED: TouchedState = {
applicantName: false,
email: false,
phone: false,
requestedAccessPeriod: false,
};
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_DIGITS_REGEX = /^\d{11}$/;
function formatPhoneDisplay(digits: string): string {
const d = digits.slice(0, 11);
if (d.length <= 3) return d;
if (d.length <= 7) return `${d.slice(0, 3)}-${d.slice(3)}`;
return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7)}`;
}
function toDateString(date: Date): string {
const y = date.getFullYear();
@ -89,7 +76,7 @@ function calcPresetPeriod(preset: PeriodPreset): string {
return `${from} ~ ${to}`;
}
function validateForm(form: FormState, phoneRaw: string): ErrorState {
function validateForm(form: FormState): ErrorState {
const errors: ErrorState = {};
if (!form.applicantName.trim()) {
@ -102,12 +89,8 @@ function validateForm(form: FormState, phoneRaw: string): ErrorState {
errors.email = '올바른 이메일 형식을 입력해주세요.';
}
if (phoneRaw && !PHONE_DIGITS_REGEX.test(phoneRaw)) {
errors.phone = '전화번호는 11자리 숫자로 입력해주세요. (예: 01012345678)';
}
if (!form.requestedAccessPeriod.trim()) {
errors.requestedAccessPeriod = '요청 접근 기간을 선택해주세요.';
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
}
return errors;
@ -118,8 +101,6 @@ export default function BypassAccessRequest() {
const navigate = useNavigate();
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [phoneRaw, setPhoneRaw] = useState('');
const [phoneDisplay, setPhoneDisplay] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submittedId, setSubmittedId] = useState<number | null>(null);
@ -139,16 +120,9 @@ export default function BypassAccessRequest() {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 11);
setPhoneRaw(digits);
setPhoneDisplay(formatPhoneDisplay(digits));
setForm((prev) => ({ ...prev, phone: digits }));
};
const handleBlur = (field: keyof TouchedState) => {
setTouched((prev) => ({ ...prev, [field]: true }));
const currentErrors = validateForm(form, phoneRaw);
const currentErrors = validateForm(form);
setErrors(currentErrors);
};
@ -204,9 +178,9 @@ export default function BypassAccessRequest() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitAttempted(true);
setTouched({ applicantName: true, email: true, phone: true, requestedAccessPeriod: true });
setTouched({ applicantName: true, email: true, requestedAccessPeriod: true });
const currentErrors = validateForm(form, phoneRaw);
const currentErrors = validateForm(form);
if (periodManual && periodRangeError) {
return;
@ -228,7 +202,7 @@ export default function BypassAccessRequest() {
organization: form.organization,
purpose: form.purpose,
email: form.email,
phone: form.phone,
phone: '',
requestedAccessPeriod: form.requestedAccessPeriod,
};
const res = await bypassAccountApi.submitRequest(requestData);
@ -258,9 +232,9 @@ export default function BypassAccessRequest() {
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text">Bypass API </h1>
<h1 className="text-2xl font-bold text-wing-text">S&P API </h1>
<p className="mt-1 text-sm text-wing-muted">
Bypass API . .
S&P API . .
</p>
</div>
@ -283,8 +257,6 @@ export default function BypassAccessRequest() {
type="button"
onClick={() => {
setForm(INITIAL_FORM);
setPhoneRaw('');
setPhoneDisplay('');
setErrors({});
setTouched(INITIAL_TOUCHED);
setSubmitAttempted(false);
@ -304,7 +276,7 @@ export default function BypassAccessRequest() {
onClick={() => navigate('/bypass-catalog')}
className="mt-3 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors"
>
Bypass API
S&P API
</button>
</div>
) : (
@ -346,7 +318,7 @@ export default function BypassAccessRequest() {
</div>
</div>
{/* 이메일 + 전화번호 */}
{/* 이메일 + 사용 기간 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="field-email">
<label className="block text-sm font-medium text-wing-text mb-1">
@ -365,21 +337,82 @@ export default function BypassAccessRequest() {
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div id="field-phone">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={phoneDisplay}
onChange={handlePhoneChange}
onBlur={() => handleBlur('phone')}
placeholder="010-0000-0000"
disabled={submitting}
className={inputClass('phone')}
/>
{showError('phone') && (
<p className="mt-1 text-xs text-red-500">{errors.phone}</p>
<div id="field-requestedAccessPeriod">
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-wing-text">
<span className="text-red-500">*</span>
</label>
<button
type="button"
onClick={handleToggleManual}
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
>
<span> </span>
<span
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
periodManual ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
{!periodManual ? (
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handlePresetClick(preset)}
disabled={submitting}
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
selectedPreset === preset
? 'bg-wing-accent text-white border-wing-accent'
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
}`}
>
{preset}
</button>
))}
</div>
) : (
<div className="flex items-center gap-3">
<div className="flex-1">
<input
type="date"
value={periodFrom}
onChange={(e) => handlePeriodFromChange(e.target.value)}
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
<div className="flex-1">
<input
type="date"
value={periodTo}
onChange={(e) => handlePeriodToChange(e.target.value)}
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
</div>
)}
{form.requestedAccessPeriod && !periodRangeError && (
<p className="mt-2 text-xs text-wing-muted">
: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
</p>
)}
{periodRangeError && (
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
)}
{showError('requestedAccessPeriod') && !periodRangeError && (
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
)}
</div>
</div>
@ -399,89 +432,6 @@ export default function BypassAccessRequest() {
/>
</div>
{/* 요청 접근 기간 */}
<div id="field-requestedAccessPeriod">
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2 mb-2">
<button
type="button"
onClick={handleToggleManual}
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
>
<span> </span>
<span
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
periodManual ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</span>
</button>
</div>
{!periodManual ? (
/* Preset buttons */
<div className="flex flex-wrap gap-2">
{PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => handlePresetClick(preset)}
disabled={submitting}
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
selectedPreset === preset
? 'bg-wing-accent text-white border-wing-accent'
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
}`}
>
{preset}
</button>
))}
</div>
) : (
/* Manual date range inputs */
<div className="flex items-center gap-3">
<div className="flex-1">
<input
type="date"
value={periodFrom}
onChange={(e) => handlePeriodFromChange(e.target.value)}
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
<div className="flex-1">
<input
type="date"
value={periodTo}
onChange={(e) => handlePeriodToChange(e.target.value)}
disabled={submitting}
className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
</div>
)}
{/* Period preview */}
{form.requestedAccessPeriod && !periodRangeError && (
<p className="mt-2 text-xs text-wing-muted">
: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
</p>
)}
{periodRangeError && (
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
)}
{showError('requestedAccessPeriod') && !periodRangeError && (
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end">

파일 보기

@ -337,10 +337,6 @@ export default function BypassAccountManagement() {
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{editTarget.phone ?? '-'}</span>
</div>
</div>
</div>

파일 보기

@ -293,14 +293,10 @@ export default function BypassAccountRequests() {
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.phone ?? '-'}</span>
</div>
<div className="col-span-2">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
</div>
</div>
<div className="mt-2 text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
</div>
{approveTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
@ -396,14 +392,10 @@ export default function BypassAccountRequests() {
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
</div>
<div>
<span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.phone ?? '-'}</span>
</div>
<div className="col-span-2">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
</div>
</div>
<div className="mt-2 text-xs">
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
</div>
{rejectTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
@ -485,10 +477,6 @@ export default function BypassAccountRequests() {
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"></div>
<div className="text-wing-text">{detailTarget.phone ?? '-'}</div>
</div>
</div>
<div>
<div className="text-xs text-wing-muted mb-0.5"> </div>
@ -549,6 +537,25 @@ export default function BypassAccountRequests() {
</button>
</>
)}
{detailTarget.status === 'REJECTED' && (
<button
type="button"
onClick={async () => {
try {
await bypassAccountApi.reopenRequest(detailTarget.id);
setDetailTarget(null);
showToast('재심사 상태로 변경되었습니다.', 'success');
await loadRequests();
} catch (err) {
showToast('재심사 처리 실패', 'error');
console.error(err);
}
}}
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors"
>
</button>
)}
</div>
</div>
</div>

파일 보기

@ -92,9 +92,9 @@ export default function BypassCatalog() {
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">Bypass API </h1>
<h1 className="text-2xl font-bold text-wing-text">S&P Global API </h1>
<p className="mt-1 text-sm text-wing-muted">
Bypass API . Swagger UI에서 .
S&P Global Maritime API . Swagger UI에서 .
</p>
</div>
<a

파일 보기

@ -12,13 +12,13 @@ const sections = [
menuCount: 6,
},
{
title: 'S&P Bypass',
description: 'S&P Bypass API 관리',
detail: 'API 등록, 코드 생성 관리, 테스트',
title: 'S&P Global API',
description: 'S&P Global Maritime API',
detail: 'API 카탈로그, API 계정 신청',
path: '/bypass-catalog',
icon: '🔗',
icon: '🌐',
iconClass: 'gc-card-icon gc-card-icon-guide',
menuCount: 1,
menuCount: 5,
},
{
title: 'S&P Risk & Compliance',

파일 보기

@ -45,6 +45,12 @@
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Boot Starter Mail -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Spring Boot Starter Batch -->
<dependency>
<groupId>org.springframework.boot</groupId>

파일 보기

@ -114,4 +114,10 @@ public class BypassAccountController {
@RequestBody BypassRequestReviewRequest review) {
return ResponseEntity.ok(ApiResponse.success(requestService.rejectRequest(id, review)));
}
@PostMapping("/requests/{id}/reopen")
@Operation(summary = "신청 재심사 (거절 → 대기)")
public ResponseEntity<ApiResponse<BypassRequestResponse>> reopenRequest(@PathVariable Long id) {
return ResponseEntity.ok(ApiResponse.success(requestService.reopenRequest(id)));
}
}

파일 보기

@ -26,6 +26,7 @@ public class BypassApiRequestService {
private final BypassApiRequestRepository requestRepository;
private final BypassApiAccountRepository accountRepository;
private final BypassApiAccountService accountService;
private final EmailService emailService;
@Transactional
public BypassRequestResponse submitRequest(BypassRequestSubmitRequest request) {
@ -82,6 +83,18 @@ public class BypassApiRequestService {
request.setAccount(accountRepository.getReferenceById(accountResponse.getId()));
requestRepository.save(request);
// 이메일 발송 (비동기)
if (request.getEmail() != null && !request.getEmail().isBlank()) {
emailService.sendAccountApprovedEmail(
request.getEmail(),
request.getApplicantName(),
accountResponse.getUsername(),
accountResponse.getPlainPassword(),
review.getAccessStartDate() != null ? review.getAccessStartDate().toString() : null,
review.getAccessEndDate() != null ? review.getAccessEndDate().toString() : null
);
}
log.info("Bypass API 계정 신청 승인: requestId={}, accountUsername={}",
id, accountResponse.getUsername());
return accountResponse;
@ -100,10 +113,36 @@ public class BypassApiRequestService {
request.setRejectReason(review.getRejectReason());
BypassApiRequest saved = requestRepository.save(request);
// 이메일 발송 (비동기)
if (request.getEmail() != null && !request.getEmail().isBlank()) {
emailService.sendRequestRejectedEmail(
request.getEmail(),
request.getApplicantName(),
review.getRejectReason()
);
}
log.info("Bypass API 계정 신청 거절: requestId={}", id);
return toResponse(saved);
}
@Transactional
public BypassRequestResponse reopenRequest(Long id) {
BypassApiRequest request = findOrThrow(id);
if (request.getStatus() != RequestStatus.REJECTED) {
throw new IllegalStateException("거절된 신청만 재심사할 수 있습니다: " + request.getStatus());
}
request.setStatus(RequestStatus.PENDING);
request.setReviewedBy(null);
request.setReviewedAt(null);
request.setRejectReason(null);
BypassApiRequest saved = requestRepository.save(request);
log.info("Bypass API 계정 신청 재심사: requestId={}", id);
return toResponse(saved);
}
private BypassApiRequest findOrThrow(Long id) {
return requestRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다: " + id));

파일 보기

@ -0,0 +1,84 @@
package com.snp.batch.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
/**
* 이메일 발송 공통 서비스
*
* 비동기(@Async) 발송하여 API 응답 지연을 방지한다.
* Thymeleaf 템플릿 엔진으로 HTML 이메일을 구성한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender;
private final TemplateEngine templateEngine;
@Value("${spring.mail.username:noreply@example.com}")
private String fromAddress;
@Value("${app.base-url:https://guide.gc-si.dev/snp-api}")
private String baseUrl;
/**
* Bypass API 계정 발급 이메일 발송
*/
@Async
public void sendAccountApprovedEmail(String toEmail, String applicantName,
String username, String plainPassword,
String accessStartDate, String accessEndDate) {
Context context = new Context();
context.setVariable("applicantName", applicantName);
context.setVariable("username", username);
context.setVariable("password", plainPassword);
context.setVariable("accessStartDate", accessStartDate != null ? accessStartDate : "-");
context.setVariable("accessEndDate", accessEndDate != null ? accessEndDate : "-");
context.setVariable("swaggerUrl", baseUrl + "/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API");
String html = templateEngine.process("email/account-approved", context);
sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정이 발급되었습니다", html);
}
/**
* Bypass API 계정 신청 거절 이메일 발송
*/
@Async
public void sendRequestRejectedEmail(String toEmail, String applicantName,
String rejectReason) {
Context context = new Context();
context.setVariable("applicantName", applicantName);
context.setVariable("rejectReason", rejectReason != null && !rejectReason.isBlank()
? rejectReason : "별도 안내 없음");
context.setVariable("requestUrl", baseUrl + "/bypass-access-request");
String html = templateEngine.process("email/request-rejected", context);
sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정 신청이 거절되었습니다", html);
}
private void sendHtmlEmail(String to, String subject, String htmlContent) {
try {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true);
mailSender.send(message);
log.info("이메일 발송 완료: to={}, subject={}", to, subject);
} catch (MessagingException e) {
log.error("이메일 발송 실패: to={}, subject={}", to, subject, e);
}
}
}

파일 보기

@ -38,6 +38,17 @@ spring:
prefix: classpath:/templates/
suffix: .html
# Email (SMTP)
mail:
host: smtp.gmail.com
port: 587
username: hjkim4@gcsc.co.kr
password: khow urga yyxh rciq
properties:
mail.smtp.auth: true
mail.smtp.starttls.enable: true
mail.smtp.starttls.required: true
# Quartz Scheduler Configuration - Using JDBC Store for persistence
quartz:
job-store-type: jdbc # JDBC store for schedule persistence

파일 보기

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background:#0f172a; padding:24px 32px;">
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&amp;P Data Platform</h1>
</div>
<!-- Body -->
<div style="padding:32px;">
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
</p>
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
S&amp;P API 계정 발급 신청이 승인되었습니다.<br/>
아래 계정 정보로 API를 사용하실 수 있습니다.
</p>
<!-- Credentials box -->
<div style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:20px; margin-bottom:24px;">
<table style="width:100%; font-size:14px; color:#1e293b;">
<tr>
<td style="padding:6px 0; color:#64748b; width:100px;">사용자명</td>
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${username}">bypass_xxxxxxxx</td>
</tr>
<tr>
<td style="padding:6px 0; color:#64748b;">비밀번호</td>
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${password}">xxxxxxxx</td>
</tr>
<tr>
<td style="padding:6px 0; color:#64748b;">유효 기간</td>
<td style="padding:6px 0;" th:text="${accessStartDate} + ' ~ ' + ${accessEndDate}">2026-01-01 ~ 2026-12-31</td>
</tr>
</table>
</div>
<!-- Warning -->
<div style="background:#fef3c7; border:1px solid #fcd34d; border-radius:8px; padding:12px 16px; margin-bottom:24px;">
<p style="font-size:12px; color:#92400e; margin:0; line-height:1.5;">
이 비밀번호는 이메일로 1회만 안내됩니다. 안전한 곳에 보관해주세요.<br/>
비밀번호 분실 시 관리자에게 재설정을 요청하세요.
</p>
</div>
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
<a th:href="${swaggerUrl}" style="color:#2563eb; text-decoration:none; font-weight:500;">Swagger UI</a>에서
[Authorize] 버튼을 클릭하여 위 계정으로 인증 후 사용할 수 있습니다.
</p>
</div>
<!-- Footer -->
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
<p style="font-size:11px; color:#94a3b8; margin:0;">
본 메일은 S&amp;P Data Platform에서 자동 발송되었습니다.
</p>
</div>
</div>
</body>
</html>

파일 보기

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
</head>
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background:#0f172a; padding:24px 32px;">
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&amp;P Data Platform</h1>
</div>
<!-- Body -->
<div style="padding:32px;">
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
</p>
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
S&amp;P API 계정 신청이 거절되었습니다.
</p>
<!-- Reason box -->
<div style="background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:16px; margin-bottom:24px;">
<p style="font-size:12px; color:#991b1b; margin:0 0 4px; font-weight:bold;">거절 사유</p>
<p style="font-size:14px; color:#1e293b; margin:0; line-height:1.6; white-space:pre-wrap;" th:text="${rejectReason}">거절 사유 내용</p>
</div>
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
추가 문의 사항이 있으시면 관리자에게 연락해주세요.<br/>
신청서를 수정하여 다시 제출하실 수 있습니다.
</p>
<!-- 신청 바로가기 -->
<div style="text-align:center; margin-top:24px;">
<a th:href="${requestUrl}"
style="display:inline-block; padding:10px 24px; background:#0f172a; color:#ffffff; font-size:13px; font-weight:600; text-decoration:none; border-radius:8px;">
API 계정 신청 바로가기
</a>
</div>
</div>
<!-- Footer -->
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
<p style="font-size:11px; color:#94a3b8; margin:0;">
본 메일은 S&amp;P Data Platform에서 자동 발송되었습니다.
</p>
</div>
</div>
</body>
</html>