feat(notify): 이메일 출입증 발송(EmailPassNotifier) 추가
- spring-boot-starter-mail 기반 EmailPassNotifier: acs.sms.provider=email일 때 활성. 발신=acs.mail.from(기본 dept_itcm000@bok.or.kr), 수신=방문자 email, QR PNG를 첨부하고 공개 출입증 링크를 본문에 포함. 발송 실패는 log(승인 롤백 없음). - provider 스위치에 email 추가(dev/hanbank/email). prod는 spring.mail.* 를 ACS_MAIL_* env로 주입(인증 선택), .env.example·docker-compose 반영. - 테스트: EmailPassNotifierTest(발신/수신/제목/QR 첨부 구성, 이메일 없으면 미발송). 검증: 전체 5개 테스트 통과. 로컬 SMTP(aiosmtpd:1025)로 실발송 e2e 확인 — MAIL FROM=dept_itcm000@bok.or.kr, RCPT=visitor, 제목/본문/pass-qr.png 첨부 수신 확인. 주의: 발신자가 bok.or.kr이면 SPF/DMARC상 실제 사내 SMTP 릴레이를 거쳐야 하며, ACS_MAIL_HOST/PORT(및 필요 시 인증) 확보 후 실환경 발송 배선 필요. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package com.itcenter.acs.notification;
|
||||
|
||||
import com.itcenter.acs.entity.VisitRequest;
|
||||
import com.itcenter.acs.entity.Visitor;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Sends the visitor pass by email, with the QR image attached (unlike the LMS
|
||||
* API, email can carry the image directly). The message is sent from a shared
|
||||
* departmental address ({@code acs.mail.from}) through the configured SMTP relay
|
||||
* ({@code spring.mail.*}).
|
||||
*
|
||||
* <p>Active only when {@code acs.sms.provider=email}; requires {@code spring.mail.host}
|
||||
* to be set so Spring auto-configures a {@link JavaMailSender}. A send failure is
|
||||
* logged (not thrown) so a delivery problem never rolls back the approval.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "acs.sms.provider", havingValue = "email")
|
||||
public class EmailPassNotifier implements PassNotifier {
|
||||
|
||||
private static final DateTimeFormatter WINDOW_FMT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final String fromAddress;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
public EmailPassNotifier(
|
||||
JavaMailSender mailSender,
|
||||
@Value("${acs.mail.from:dept_itcm000@bok.or.kr}") String fromAddress,
|
||||
@Value("${acs.public-base-url}") String publicBaseUrl) {
|
||||
this.mailSender = mailSender;
|
||||
this.fromAddress = fromAddress;
|
||||
this.publicBaseUrl = publicBaseUrl.endsWith("/")
|
||||
? publicBaseUrl.substring(0, publicBaseUrl.length() - 1)
|
||||
: publicBaseUrl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendPass(VisitRequest visitRequest, byte[] qrPng) {
|
||||
Visitor visitor = visitRequest.getVisitor();
|
||||
String to = visitor != null ? visitor.getEmail() : null;
|
||||
if (to == null || to.isBlank()) {
|
||||
log.warn("[email] 방문자 이메일이 없어 출입증 메일을 발송하지 못했습니다. visitRequestId={}", visitRequest.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
|
||||
helper.setFrom(fromAddress);
|
||||
helper.setTo(to);
|
||||
helper.setSubject("[IT센터 출입증] 방문 승인 안내");
|
||||
helper.setText(buildBody(visitRequest), false);
|
||||
helper.addAttachment("pass-qr.png", new ByteArrayResource(qrPng), "image/png");
|
||||
|
||||
mailSender.send(message);
|
||||
log.info("[email] 출입증 메일 발송 성공 → {} (from={}, QR {} bytes)", to, fromAddress, qrPng.length);
|
||||
} catch (Exception e) {
|
||||
log.warn("[email] 출입증 메일 발송 실패 → {} : {}", to, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildBody(VisitRequest vr) {
|
||||
Visitor visitor = vr.getVisitor();
|
||||
String name = visitor != null ? visitor.getName() : "방문자";
|
||||
String zone = vr.getZoneName() != null ? vr.getZoneName() : "-";
|
||||
String window = WINDOW_FMT.format(vr.getVisitFrom()) + " ~ " + WINDOW_FMT.format(vr.getVisitTo());
|
||||
String link = publicBaseUrl + "/pass/" + vr.getQrToken();
|
||||
return String.format(
|
||||
"[IT센터 출입증]\n%s님, 출입 신청이 승인되었습니다.\n출입구역: %s\n출입기간: %s\n"
|
||||
+ "첨부된 QR 이미지 또는 아래 링크에서 출입증을 확인하세요.\n%s\n"
|
||||
+ "전산실 입장 시 QR을 출입관리시스템에 입력하세요.",
|
||||
name, zone, window, link);
|
||||
}
|
||||
}
|
||||
@@ -31,14 +31,25 @@ spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
|
||||
# ===== SMS/LMS pass delivery =====
|
||||
# provider=dev -> logs + writes QR to outbox-dir; provider=hanbank -> sends LMS
|
||||
# with the public pass link via the in-house DMZ message API.
|
||||
# ===== Pass delivery =====
|
||||
# provider=dev -> logs + writes QR to outbox-dir; hanbank -> LMS via DMZ message API;
|
||||
# email -> emails the pass (QR attached) via the SMTP relay below.
|
||||
acs.sms.provider=${ACS_SMS_PROVIDER:dev}
|
||||
acs.sms.api-url=${ACS_SMS_API_URL:http://210.104.132.59:8000}
|
||||
acs.sms.outbox-dir=${ACS_SMS_OUTBOX_DIR:/app/sms-outbox}
|
||||
# Base URL the SMS link points to — must be reachable from the visitor's phone.
|
||||
# Base URL the SMS/email link points to — must be reachable from the visitor's device.
|
||||
acs.public-base-url=${ACS_PUBLIC_BASE_URL:http://localhost}
|
||||
|
||||
# ===== Email pass delivery (ACS_SMS_PROVIDER=email) =====
|
||||
# Sender + corporate SMTP relay. Auth is optional (internal relays often allow
|
||||
# unauthenticated sending from trusted IPs); set username/password only if required.
|
||||
acs.mail.from=${ACS_MAIL_FROM:dept_itcm000@bok.or.kr}
|
||||
spring.mail.host=${ACS_MAIL_HOST:}
|
||||
spring.mail.port=${ACS_MAIL_PORT:25}
|
||||
spring.mail.username=${ACS_MAIL_USERNAME:}
|
||||
spring.mail.password=${ACS_MAIL_PASSWORD:}
|
||||
spring.mail.properties.mail.smtp.auth=${ACS_MAIL_SMTP_AUTH:false}
|
||||
spring.mail.properties.mail.smtp.starttls.enable=${ACS_MAIL_STARTTLS:false}
|
||||
|
||||
logging.level.root=WARN
|
||||
logging.level.com.itcenter.acs=INFO
|
||||
|
||||
@@ -32,14 +32,22 @@ spring.flyway.enabled=false
|
||||
spring.servlet.multipart.max-file-size=10MB
|
||||
spring.servlet.multipart.max-request-size=10MB
|
||||
|
||||
# ===== SMS/LMS pass delivery =====
|
||||
# provider=dev -> LoggingPassNotifier: logs + writes QR to outbox-dir (no network)
|
||||
# provider=hanbank -> HanbankMessagePassNotifier: sends an LMS with the public
|
||||
# pass link via the in-house DMZ message API (requires network reachability).
|
||||
# ===== Pass delivery =====
|
||||
# provider=dev -> LoggingPassNotifier: logs + writes QR to outbox-dir (no network)
|
||||
# provider=hanbank -> HanbankMessagePassNotifier: LMS with the public pass link via the
|
||||
# in-house DMZ message API (requires network reachability).
|
||||
# provider=email -> EmailPassNotifier: emails the pass (QR attached) via spring.mail.*.
|
||||
acs.sms.provider=dev
|
||||
acs.sms.outbox-dir=./sms-outbox
|
||||
acs.sms.api-url=http://210.104.132.59:8000
|
||||
# Base URL the SMS link points to (the visitor's public pass page).
|
||||
# Base URL the SMS/email link points to (the visitor's public pass page).
|
||||
acs.public-base-url=http://localhost:5173
|
||||
|
||||
# ===== Email pass delivery (provider=email) =====
|
||||
# Sender (shared departmental mailbox) and SMTP relay. For local testing point
|
||||
# spring.mail.host at a debug SMTP server (e.g. aiosmtpd on 127.0.0.1:1025).
|
||||
acs.mail.from=dept_itcm000@bok.or.kr
|
||||
# spring.mail.host=127.0.0.1
|
||||
# spring.mail.port=1025
|
||||
|
||||
spring.profiles.active=local
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.itcenter.acs;
|
||||
|
||||
import com.itcenter.acs.entity.VisitRequest;
|
||||
import com.itcenter.acs.entity.Visitor;
|
||||
import com.itcenter.acs.notification.EmailPassNotifier;
|
||||
import jakarta.mail.Multipart;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.JavaMailSenderImpl;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/** Verifies the pass email is built with the right sender, recipient and QR attachment — no SMTP server needed. */
|
||||
class EmailPassNotifierTest {
|
||||
|
||||
@Test
|
||||
void buildsEmailWithSenderRecipientAndQrAttachment() throws Exception {
|
||||
JavaMailSender sender = mock(JavaMailSender.class);
|
||||
// real (unconnected) sender only to mint an empty MimeMessage the notifier fills in
|
||||
when(sender.createMimeMessage()).thenReturn(new JavaMailSenderImpl().createMimeMessage());
|
||||
|
||||
EmailPassNotifier notifier =
|
||||
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173/");
|
||||
|
||||
Visitor visitor = new Visitor();
|
||||
visitor.setName("김방문");
|
||||
visitor.setEmail("visitor@example.com");
|
||||
VisitRequest vr = new VisitRequest();
|
||||
vr.setVisitor(visitor);
|
||||
vr.setZoneName("전산실");
|
||||
vr.setVisitFrom(LocalDateTime.of(2026, 7, 3, 9, 0));
|
||||
vr.setVisitTo(LocalDateTime.of(2026, 7, 3, 18, 0));
|
||||
vr.setQrToken("token-abc");
|
||||
|
||||
notifier.sendPass(vr, new byte[]{1, 2, 3, 4});
|
||||
|
||||
ArgumentCaptor<MimeMessage> captor = ArgumentCaptor.forClass(MimeMessage.class);
|
||||
verify(sender).send(captor.capture());
|
||||
MimeMessage msg = captor.getValue();
|
||||
|
||||
assertThat(msg.getFrom()[0].toString()).contains("dept_itcm000@bok.or.kr");
|
||||
assertThat(msg.getAllRecipients()[0].toString()).isEqualTo("visitor@example.com");
|
||||
assertThat(msg.getSubject()).contains("출입증");
|
||||
|
||||
Object content = msg.getContent();
|
||||
assertThat(content).isInstanceOf(Multipart.class);
|
||||
Multipart mp = (Multipart) content;
|
||||
assertThat(mp.getCount()).as("body + QR attachment").isGreaterThanOrEqualTo(2);
|
||||
boolean hasQr = false;
|
||||
for (int i = 0; i < mp.getCount(); i++) {
|
||||
String fileName = mp.getBodyPart(i).getFileName();
|
||||
if ("pass-qr.png".equals(fileName)) {
|
||||
hasQr = true;
|
||||
}
|
||||
}
|
||||
assertThat(hasQr).as("QR PNG attached").isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsWhenVisitorHasNoEmail() {
|
||||
JavaMailSender sender = mock(JavaMailSender.class);
|
||||
EmailPassNotifier notifier =
|
||||
new EmailPassNotifier(sender, "dept_itcm000@bok.or.kr", "http://localhost:5173");
|
||||
|
||||
Visitor visitor = new Visitor();
|
||||
visitor.setName("무이메일");
|
||||
VisitRequest vr = new VisitRequest();
|
||||
vr.setVisitor(visitor);
|
||||
vr.setVisitFrom(LocalDateTime.now());
|
||||
vr.setVisitTo(LocalDateTime.now().plusHours(1));
|
||||
vr.setQrToken("t");
|
||||
|
||||
notifier.sendPass(vr, new byte[]{1});
|
||||
|
||||
// no email → never touches the mail sender
|
||||
verify(sender, org.mockito.Mockito.never()).send(org.mockito.Mockito.any(MimeMessage.class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user