From 6946bf15bb4eec4d52ec70046a70819476f6c313 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Jul 2026 15:19:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(notify):=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=B6=9C=EC=9E=85=EC=A6=9D=20=EB=B0=9C=EC=86=A1(EmailPassNotif?= =?UTF-8?q?ier)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/pom.xml | 5 ++ .../acs/notification/EmailPassNotifier.java | 85 +++++++++++++++++++ .../resources/application-prod.properties | 19 ++++- .../src/main/resources/application.properties | 18 ++-- .../itcenter/acs/EmailPassNotifierTest.java | 85 +++++++++++++++++++ infra/.env.example | 14 ++- infra/docker-compose.yml | 8 ++ 7 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java create mode 100644 backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java diff --git a/backend/pom.xml b/backend/pom.xml index 14a07cb..30f12b7 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -41,6 +41,11 @@ org.springframework.boot spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-mail + org.postgresql diff --git a/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java b/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java new file mode 100644 index 0000000..762a0a2 --- /dev/null +++ b/backend/src/main/java/com/itcenter/acs/notification/EmailPassNotifier.java @@ -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.*}). + * + *

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); + } +} diff --git a/backend/src/main/resources/application-prod.properties b/backend/src/main/resources/application-prod.properties index 5df2b6e..cbdfd90 100644 --- a/backend/src/main/resources/application-prod.properties +++ b/backend/src/main/resources/application-prod.properties @@ -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 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 235505b..89f785e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java b/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java new file mode 100644 index 0000000..a61b160 --- /dev/null +++ b/backend/src/test/java/com/itcenter/acs/EmailPassNotifierTest.java @@ -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 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)); + } +} diff --git a/infra/.env.example b/infra/.env.example index b8a4a63..aca38a0 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -8,11 +8,23 @@ POSTGRES_DB=acs # Web (nginx) published port WEB_PORT=80 -# ===== Pass delivery (SMS/LMS) ===== +# ===== Pass delivery ===== # dev : no network — logs the message + writes the QR image to the outbox # hanbank : sends an LMS with the public pass link via the in-house DMZ API +# email : emails the pass (QR attached) via the SMTP relay below ACS_SMS_PROVIDER=dev ACS_SMS_API_URL=http://210.104.132.59:8000 + +# ===== Email pass delivery (only used when ACS_SMS_PROVIDER=email) ===== +# Sender = shared departmental mailbox; relay = corporate SMTP server. +ACS_MAIL_FROM=dept_itcm000@bok.or.kr +ACS_MAIL_HOST= +ACS_MAIL_PORT=25 +# Set only if the relay requires SMTP AUTH (internal relays often do not): +ACS_MAIL_USERNAME= +ACS_MAIL_PASSWORD= +ACS_MAIL_SMTP_AUTH=false +ACS_MAIL_STARTTLS=false # URL the SMS link points to — MUST be reachable from the visitor's phone # (the server's real address/domain, not localhost). e.g. https://acs.example.co.kr ACS_PUBLIC_BASE_URL=http://localhost diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 453d8eb..d7b29be 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -31,6 +31,14 @@ services: # Security: CORS origins (empty = same-origin via nginx); cookie Secure (enable under HTTPS) ACS_CORS_ALLOWED_ORIGINS: ${ACS_CORS_ALLOWED_ORIGINS:-} ACS_COOKIE_SECURE: ${ACS_COOKIE_SECURE:-false} + # Email pass delivery (used when ACS_SMS_PROVIDER=email) + ACS_MAIL_FROM: ${ACS_MAIL_FROM:-dept_itcm000@bok.or.kr} + ACS_MAIL_HOST: ${ACS_MAIL_HOST:-} + ACS_MAIL_PORT: ${ACS_MAIL_PORT:-25} + ACS_MAIL_USERNAME: ${ACS_MAIL_USERNAME:-} + ACS_MAIL_PASSWORD: ${ACS_MAIL_PASSWORD:-} + ACS_MAIL_SMTP_AUTH: ${ACS_MAIL_SMTP_AUTH:-false} + ACS_MAIL_STARTTLS: ${ACS_MAIL_STARTTLS:-false} depends_on: - db networks: