[BE-82] Gmail Apps Script 포워더 배치 · 실동작 검증 (FR-92 / A-1)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “3단계 — 연락 이벤트 웹훅 (FR-89·92)” → Gmail Apps Script 계약
설계 초안: 공고알림앱/gmail-forwarder/Code.gs, 공고알림앱/gmail-forwarder/README.md
변경 사항
게이트 ② 결정으로 기존 판단을 철회한 티켓입니다. BE-71이 “스크립트 자체는 범위 밖”으로 계약 부록만 남겼으나, PRD FR-92 문언(“Google Apps Script로 제공한다”)과 3단계 완료 판정(“Gmail 웹훅 1건 수신 → 검토 화면 반영 왕복 성공”)이 자급자족하려면 동작하는 스크립트가 산출물이어야 합니다.
-
설계 초안 2파일을 레포로 배치합니다 —
scripts/gmail-forwarder/Code.gs,scripts/gmail-forwarder/README.md. -
Kotlin 티켓과 분리한 이유: 언어가 JS이고 테스트 방식이 다릅니다(Kotest·Testcontainers가 아니라 실제 Gmail 계정 + 실행 로그 확인). 기존 BE 티켓에 섞으면 완료 기준이 흐려집니다.
-
스크립트가 충족해야 할 계약 — BE-47(HMAC 검증)·BE-71(수신 API)과 정확히 일치해야 합니다. 하나라도 어긋나면 401로 전량 실패합니다.
항목 값 트리거 설치형 시간 기반 5분 주기 대상 recruitment-app/inbox라벨 스레드만. 자동 키워드 검색 금지 (PRD §155)서명 대상 "{t}.{rawBody}"—rawBody는 실제 전송 본문과 같은 문자열헤더 X-Recruitment-Signature: t={t},v1={hex},X-Recruitment-Nonce: {UUID}전송 필드 providerMessageId·threadId·발신자·제목·수신 시각·텍스트 본문·첨부 메타데이터만 (PRD §156)라벨 이동 2xx 수신 후에만 processed부착 +inbox제거. 실패 시inbox유지 → 재시도 (PRD §157)비밀 웹훅 URL·공유 비밀은 Script Properties에서 조회. 하드코딩 금지 -
스레드 단위 처리와 멱등 — Gmail 라벨은 메시지가 아니라 스레드에 붙습니다. 라벨이 붙은 스레드의 모든 메시지를 전송하고 전부 2xx일 때만 라벨을 이동합니다. 이미 보낸 메시지가 섞여 있어도 앱이
providerMessageId유니크로 멱등 처리(202 + duplicated: true)하므로 안전하며, 덕분에 스크립트가 메시지 단위 처리 상태를 보관하지 않습니다. -
동시 실행 방지 —
LockService스크립트 락으로 이전 회차 미완료 시 이번 회차를 건너뜁니다. 5-1. 빈 회차에는 POST를 보내지 않습니다 (D — 검증 완료). 라벨 대상 스레드가 0건이면 요청을 만들지 않고 즉시 종료합니다.- 앱의
webhook_receipt_logs가 인덱스 없이 운영되는 전제입니다(dba 판정). 5분 주기는 연 105,120회이므로 빈 회차마다 POST하면 첫해에 수신 이력이 10만 행을 넘어 인덱스 + 정리 배치가 둘 다 필요해집니다. - 초안에는 명시적 early return + 주석으로 고정했습니다 — 루프가 안 도는 부수 효과에 의존하면 나중에 하트비트를 추가하다 전제가 깨집니다.
- 스텁 하네스로 검증 완료: 스레드 0건 →
UrlFetchApp.fetch호출 0회, 2건 → 2회. - 하트비트·연결 확인 목적이라도 이 자리에 요청을 추가하면 안 됩니다 — 터널 상태 확인은 앱의
GET /api/operations/tunnel-status가 담당합니다.
- 앱의
-
설치 보조 함수 2개 —
setUp()(라벨 2종 생성 + 트리거 등록, 중복 방지) /dryRunOnce()(라벨을 이동하지 않고 1건만 전송해 응답 코드 확인). -
설치 절차 문서가 자급자족해야 합니다 — Apps Script 프로젝트 생성 → Script Properties 설정 →
setUp실행 →dryRunOnce점검 → 정상 운용 확인 + 실패 status별 원인 표.
완료 기준: 실제 Gmail 계정에서 로컬 웹훅으로 1건 전송 성공(2xx) + 라벨이 inbox → processed 로 이동하는 것을 실행 로그로 확인합니다. 초안과 실제 Apps Script 런타임 동작이 다르면 스크립트를 수정하고 그 근거를 남깁니다.
롤백: 트리거 삭제 또는 앱의 contact.inbound-webhook 플래그 OFF. 둘 다 유실 없이 멈춥니다(라벨 유지 → 재시도 대기).
의존
- BE-71 (연락 이벤트 수신 API — 전송 대상)
- BE-47 (HMAC 검증 — 서명 규약의 상대편)
다이어그램
처리 흐름
sequenceDiagram participant T as 시간 트리거(5분) participant S as forwardLabeledMessages participant G as GmailApp participant A as 앱 웹훅 T->>S: 실행 S->>S: LockService 획득 (실패 시 건너뜀) S->>G: inbox 라벨 스레드 조회 loop 스레드마다 loop 메시지마다 S->>S: rawBody 조립 + HMAC 서명 S->>A: POST (서명·nonce 헤더) alt 2xx A-->>S: 202 (중복이면 duplicated=true) else 비2xx·예외 A-->>S: 실패 → 스레드 중단 end end alt 전부 2xx S->>G: processed 부착 + inbox 제거 else 일부 실패 S->>G: 라벨 유지 (다음 회차 재시도) end end
클래스 의존
flowchart LR subgraph Script["scripts/gmail-forwarder"] Entry[forwardLabeledMessages] Thread[processThread_] Send[sendMessage_] Body[buildRawBody_] Sign[computeSignature_] Attach[collectAttachmentMetadata_] Setup[setUp] Dry[dryRunOnce] end subgraph External["외부"] Props[Script Properties] Gmail[GmailApp] Webhook[앱 웹훅] end Entry --> Thread Thread --> Send Send --> Body Send --> Sign Body --> Attach Entry --> Props Thread --> Gmail Send --> Webhook Setup --> Gmail Dry --> Send
테스트 케이스
Kotest가 아니라 실제 Gmail 계정 + Apps Script 실행 로그로 검증합니다.
setUp실행 후 라벨 2종이 생성되고 5분 주기 트리거가 1개만 등록된다setUp을 두 번 실행해도 트리거가 중복되지 않는다 (멱등)- Script Properties가 비어 있으면 실행 즉시 예외를 던지고 메일을 보내지 않는다
dryRunOnce가 2xx를 받고 라벨은 이동하지 않는다forwardLabeledMessages실행 후 라벨이inbox→processed로 이동한다- 앱이 401을 반환하면 라벨이
inbox에 그대로 남는다 - 공유 비밀을 틀리게 설정하면 401이고 라벨이 유지된다 (서명 규약 일치 검증)
- 같은 스레드를 두 번 처리해도 앱에 이벤트가 1건만 생긴다 (
duplicated: true확인) - 첨부가 있는 메일에서 파일명·MIME·크기만 전송되고 바이너리가 포함되지 않는다
- 첨부 21건인 메일은 20건까지만 전송하고 경고를 로그에 남긴다 (경계값)
- 본문 20,000자 초과 메일이 절단되어 전송되고 앱이 400을 내지 않는다 (경계값)
"홍길동 <a@B.com>"형식 발신자에서 이름·주소가 분리되고 주소가 소문자로 정규화된다- 발신자가 주소만(
a@b.com)인 경우에도 파싱된다 - 스레드에 메시지가 2건이고 두 번째가 실패하면 라벨이 이동하지 않는다
contact.inbound-webhook플래그 OFF면 404를 받고 라벨이 유지된다- 라벨이 붙은 스레드가 0건이면
UrlFetchApp.fetch가 한 번도 호출되지 않는다 (D —webhook_receipt_logs인덱스 미생성 전제) - 0건 회차가 앱의
webhook_receipt_logs에 아무 행도 남기지 않는다