- 100-600 번호 체계로 문서 재구성 (Part-Chapter-Section) - 철학과 배경, 핵심 설계, 기술 아키텍처, 성장과 진화, 비즈니스와 미래, 부록으로 구분 - 새로운 챕터 추가: 기억-감정-윤리 삼각형, DID 기반 정체성, 스카웃 시스템 등 - 프로젝트 종합 v3로 업데이트: 핵심 철학 섹션 추가, 현재 상태 반영 - README.md 전면 개편: 책 목차 기반 구조 반영 - 구버전 문서는 _archive로 이동, troubleshooting은 유지
277 lines
9.5 KiB
Markdown
277 lines
9.5 KiB
Markdown
# 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 자동화가 완전히 작동합니다!
|
||
|