# 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, ngrok 3200 등)을 그대로 보존합니다. 로컬 개발부터 프로덕션 배포(Nginx + Let’s 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-sub` → `https://<HTTPS 도메인>/push`                                |
| **도메인 제한**     | Org Policy `iam.allowedPolicyMemberDomains` – \`\` 만 허용 → 우회 절차 포함           |

---

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

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

python auth_flow.py   # 아래 스크립트 참고
```

```python
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 변수 사용)

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

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

# 2‑2) 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

# 2‑3) 토픽 생성 및 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. **우회 절차**
   ```bash
   # 정책 리셋 (임시로 차단 해제)
   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 도메인)

```bash
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만 수정**합니다.

### 3‑A 구독 URL만 바꾸기

```bash
# 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 도착.

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

```bash
# 메시지 중복·유실 리스크가 있으므로 특별한 이유가 없으면 modify‑push‑config 권장

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) 최소 구현

```python
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

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

### cURL 발송 테스트

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

---

## 5. 운영 배포 시나리오

### 5‑A. 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 반영.

### 5‑B. Cloud Run

```bash
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 자동화가 완전히 작동합니다!

