DOCS/_archive/docs/setups/Email_readme.md
happybell80 725ad0876c fix: 문서 파일 실행 권한 제거
- 모든 .md, .html 파일 권한을 644로 정상화
- .gitignore 파일 권한도 644로 수정
- 문서 파일에 실행 권한은 불필요하고 보안상 바람직하지 않음
- deprecated 아이디어 폴더 생성 및 레벨별 UI 변경 아이디어 이동
2025-08-18 00:37:51 +09:00

9.5 KiB
Raw Blame History

Gmail Push + Send with FastAPI (전체 가이드)

범위 FastAPI 백엔드 한 곳에서 다음 두 가지를 모두 처리하는 완전한 단계별 설명(생략 없음)

  1. 새 메일 실시간 수신: Gmail API users.watch → Pub/Sub Push → FastAPI /push
  2. 메일 발송: Gmail API users.messages.send

아래 문서는 우리가 실행한 모든 셸 명령어·권한 설정·오류 해결(Org Policy, IAM, ngrok3200 등)을 그대로 보존합니다. 로컬 개발부터 프로덕션 배포(Nginx + Lets Encrypt, Cloud Run)까지 전 과정을 다룹니다.


0. 준비물 요약

항목 내용
GCP 프로젝트 email-465312 (예시) Gmail API, Pub/Sub API 활성화
OAuth 토큰 로컬 PC에서 InstalledAppFlow 로 발급한 ``(scope: gmail.readonly, gmail.send)
서비스 계정(SA) mail-bot-sa@<PROJECT_ID>.iam.gserviceaccount.com Pub/Sub Push JWT 서명용
Pub/Sub 토픽 projects/email-465312/topics/gmail-push
Push 구독 gmail-push-subhttps://<HTTPS 도메인>/push
도메인 제한 Org Policy iam.allowedPolicyMemberDomains – `` 만 허용 → 우회 절차 포함

1. 로컬 PC OAuth & 첫 토큰 발급

pip install google-auth-oauthlib google-api-python-client

python auth_flow.py   # 아래 스크립트 참고
auth_flow.py
------------
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES=["https://www.googleapis.com/auth/gmail.readonly",
        "https://www.googleapis.com/auth/gmail.send"]
flow=InstalledAppFlow.from_client_secrets_file("client_secret.json",SCOPES)
creds=flow.run_local_server(port=8080,access_type="offline",
                            include_granted_scopes="true")
open("token.json","w").write(creds.to_json())

token.json 을 안전한 경로에 보관(Secret Manager 권장).


2. IAM · Org Policy 셋업 (모든 명령은 $PROJECT_ID, $PROJECT_NUMBER, $SA 변수 사용)

export PROJECT_ID=email-465312
export PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
export SA=mail-bot-sa

# 21) 서비스 계정 생성
gcloud iam service-accounts create $SA \
  --description="Gmail push auth SA" \
  --display-name="Mail Bot SA" \
  --project=$PROJECT_ID

# 22) Pub/Sub 서비스 에이전트에 TokenCreator 권한
gcloud iam service-accounts add-iam-policy-binding \
  $SA@$PROJECT_ID.iam.gserviceaccount.com \
  --member="serviceAccount:service-${PROJECT_NUMBER}@gcp-sa-pubsub.iam.gserviceaccount.com" \
  --role="roles/iam.serviceAccountTokenCreator" \
  --project=$PROJECT_ID

# 23) 토픽 생성 및 Gmail 시스템 계정 퍼블리셔 권한
gcloud pubsub topics create gmail-push --project=$PROJECT_ID

gcloud pubsub topics add-iam-policy-binding gmail-push \
  --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
  --role=roles/pubsub.publisher \
  --project=$PROJECT_ID

⚠️ Org Policy 오류(FAILED_PRECONDITION) 해결

  1. 문제: 조직 정책 iam.allowedPolicyMemberDomains 가 외부 도메인 차단 → gmail-api-push@system.gserviceaccount.com 추가 실패.
  2. 우회 절차
    # 정책 리셋 (임시로 차단 해제)
    gcloud org-policies reset constraints/iam.allowedPolicyMemberDomains \
      --project=$PROJECT_ID
    
    # 퍼블리셔 권한 재시도 → 성공
    
    # 정책 복원
    cat >restore.yaml <<EOF
    constraint: constraints/iam.allowedPolicyMemberDomains
    listPolicy:
      allowedValues:
        - C01h2nc1g
    EOF
    gcloud resource-manager org-policies set-policy --project=$PROJECT_ID restore.yaml
    

3. Push 구독 생성 (ngrok 또는 고정 HTTPS 도메인)

export HTTPS_URL=https://abcd-1234.ngrok-free.app   # 최초 또는 변경된 외부 HTTPS 주소

gcloud pubsub subscriptions create gmail-push-sub \
  --topic=gmail-push \
  --push-endpoint=$HTTPS_URL/push \
  --push-auth-service-account=$SA@$PROJECT_ID.iam.gserviceaccount.com \
  --project=$PROJECT_ID

이미 존재 오류 — FAILED_PRECONDITION: Resource already exists → 구독은 하나만 필요하므로 새로 만들지 말고 URL만 수정합니다.

3A 구독 URL만 바꾸기

# ngrok 재시작, 도메인 변경 등으로 https 주소가 바뀐 경우
export NEW_URL=https://fc29def256f2.ngrok-free.app

gcloud pubsub subscriptions modify-push-config gmail-push-sub \
  --push-endpoint=$NEW_URL/push \
  --push-auth-service-account=$SA@$PROJECT_ID.iam.gserviceaccount.com \
  --project=$PROJECT_ID
  • --push-auth-service-account 를 생략하면 기존 값이 유지됩니다.
  • 변경은 실시간 적용 몇 초 뒤부터 새 URL로 POST 도착.

3B (참고) 구독 삭제 후 재생성

# 메시지 중복·유실 리스크가 있으므로 특별한 이유가 없으면 modifypushconfig 권장

gcloud pubsub subscriptions delete gmail-push-sub --project=$PROJECT_ID

gcloud pubsub subscriptions create gmail-push-sub \
  --topic=gmail-push \
  --push-endpoint=$NEW_URL/push \
  --push-auth-service-account=$SA@$PROJECT_ID.iam.gserviceaccount.com \
  --project=$PROJECT_ID

4. FastAPI (main.py) 최소 구현

FastAPI (main.py) 최소 구현

import os, json, base64, datetime
from fastapi import FastAPI, Request
from pydantic import BaseModel, EmailStr
from email.message import EmailMessage
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

SCOPES=[
  "https://www.googleapis.com/auth/gmail.readonly",
  "https://www.googleapis.com/auth/gmail.send",
]
CREDS=Credentials.from_authorized_user_file("token.json",SCOPES)
gmail=build("gmail","v1",credentials=CREDS,cache_discovery=False)
TOPIC=os.getenv("TOPIC","projects/email-465312/topics/gmail-push")
LAST=".last_history"

app=FastAPI()

# --- watch 등록
@app.on_event("startup")
def register_watch():
    resp=gmail.users().watch(userId="me",body={"topicName":TOPIC,"labelIds":["INBOX"]}).execute()
    open(LAST,"w").write(resp["historyId"])
    print("watch expires",datetime.datetime.fromtimestamp(int(resp["expiration"])/1000))

# --- Push 엔드포인트
@app.post("/push")
async def push(req:Request):
    msg=await req.json()
    data=json.loads(base64.urlsafe_b64decode(msg["message"]["data"]))
    new_id=data["historyId"]
    start=open(LAST).read()
    hist=gmail.users().history().list(userId="me",startHistoryId=start,historyTypes=["messageAdded"]).execute()
    for h in hist.get("history",[]):
        for m in h.get("messages",[]):
            full=gmail.users().messages().get(userId="me",id=m["id"],format="full").execute()
            print("📨",full["snippet"])
    open(LAST,"w").write(new_id)
    return {"ok":True}

# --- Send 메일
class SendReq(BaseModel):
    to: EmailStr
    subject:str
    body:str

@app.post("/send")
def send(req:SendReq):
    m=EmailMessage(); m["To"],m["Subject"]=req.to,req.subject
    m.set_content(req.body)
    raw=base64.urlsafe_b64encode(m.as_bytes()).decode()
    sent=gmail.users().messages().send(userId="me",body={"raw":raw}).execute()
    return {"id":sent["id"],"status":"sent"}

로컬 실행 & ngrok

uvicorn main:app --reload --port 8000
ngrok http 8000

cURL 발송 테스트

curl -X POST https://abcd-1234.ngrok-free.app/send \
  -H "Content-Type: application/json" \
  -d '{"to":"0914eagle@gmail.com","subject":"데모","body":"테스트 메일"}'

5. 운영 배포 시나리오

5A. VM + Nginx + Let's Encrypt

(1) DNS mailbot.ro-being.com A → 서버 IP. (2) certbot --nginx -d mailbot.ro-being.com. (3) /etc/nginx/sites-available/mailbot → proxy_pass http://127.0.0.1:8000;. (4) Pub/Sub modify-push-config 로 새 HTTPS URL 반영.

5B. Cloud Run

gcloud builds submit --tag gcr.io/$PROJECT_ID/mailbot:latest
gcloud run deploy mailbot --image gcr.io/$PROJECT_ID/mailbot:latest --allow-unauthenticated

Run URL (https://mailbot-xyz.a.run.app) 를 Push 엔드포인트로 사용.


6. 자동화 & 모니터링

항목 구현
watch 세션 7일 만료 Cloud Scheduler → 하루 1회 GET https://<DOMAIN>/register_watch
/push JWT 검증 google.oauth2.id_token.verify_oauth2_token() → info["email"] == $SA
비밀 관리 GCP Secret Manager에 token.json, .env
로깅 & 알림 Cloud Logging → Error Alert Policy

7. 트러블슈팅 로그 모음

Org Policy 오류

FAILED_PRECONDITION: User gmail-api-push@system.gserviceaccount.com is not in permitted organization
  • 해결: 정책 reset → 바인딩 → 정책 복원 (위 2단계 참조)

ngrok ERR_NGROK_3200

  • 원인: 터널 꺼짐 → ngrok http 8000 재실행, Push 구독 URL 갱신

curl (6) Could not resolve host: https

  • URL 타이포 https://https:// → 중복 https:// 하나 삭제

8. 체크리스트 (최종)

모든 체크가 끝나면 실시간 Gmail 자동화가 완전히 작동합니다!