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