[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건 수신 → 검토 화면 반영 왕복 성공”)이 자급자족하려면 동작하는 스크립트가 산출물이어야 합니다.

  1. 설계 초안 2파일을 레포로 배치합니다 — scripts/gmail-forwarder/Code.gs, scripts/gmail-forwarder/README.md.

  2. Kotlin 티켓과 분리한 이유: 언어가 JS이고 테스트 방식이 다릅니다(Kotest·Testcontainers가 아니라 실제 Gmail 계정 + 실행 로그 확인). 기존 BE 티켓에 섞으면 완료 기준이 흐려집니다.

  3. 스크립트가 충족해야 할 계약 — 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에서 조회. 하드코딩 금지
  4. 스레드 단위 처리와 멱등 — Gmail 라벨은 메시지가 아니라 스레드에 붙습니다. 라벨이 붙은 스레드의 모든 메시지를 전송하고 전부 2xx일 때만 라벨을 이동합니다. 이미 보낸 메시지가 섞여 있어도 앱이 providerMessageId 유니크로 멱등 처리(202 + duplicated: true)하므로 안전하며, 덕분에 스크립트가 메시지 단위 처리 상태를 보관하지 않습니다.

  5. 동시 실행 방지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가 담당합니다.
  6. 설치 보조 함수 2개setUp()(라벨 2종 생성 + 트리거 등록, 중복 방지) / dryRunOnce()(라벨을 이동하지 않고 1건만 전송해 응답 코드 확인).

  7. 설치 절차 문서가 자급자족해야 합니다 — Apps Script 프로젝트 생성 → Script Properties 설정 → setUp 실행 → dryRunOnce 점검 → 정상 운용 확인 + 실패 status별 원인 표.

완료 기준: 실제 Gmail 계정에서 로컬 웹훅으로 1건 전송 성공(2xx) + 라벨이 inboxprocessed 로 이동하는 것을 실행 로그로 확인합니다. 초안과 실제 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 실행 후 라벨이 inboxprocessed 로 이동한다
  • 앱이 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에 아무 행도 남기지 않는다