feat: Gmail OAuth flow, per-user SMTP, message dedup, account disable toggle

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/b77800ca-b452-4427-b0bf-63403e906916
This commit is contained in:
copilot-swe-agent[bot]
2026-03-25 11:34:52 +00:00
parent a15216c630
commit caa8eecd0a
12 changed files with 986 additions and 15 deletions
+178 -2
View File
@@ -1,10 +1,12 @@
"""Provider presets and Gmail credential management endpoints"""
from datetime import datetime, timezone
from typing import List
from datetime import datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
import httpx
import logging
from app.core.database import get_db
from app.core.deps import get_current_active_user
@@ -16,10 +18,21 @@ from app.models.schemas import (
ProviderListResponse,
GmailCredentialCreate,
GmailCredentialResponse,
GmailAuthorizeResponse,
GmailCallbackRequest,
)
from app.services.gmail_service import GmailService
router = APIRouter()
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
GMAIL_API_SCOPES = [
"openid",
"email",
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
]
# Provider presets with server configurations
PROVIDER_PRESETS: List[ProviderPreset] = [
@@ -268,3 +281,166 @@ async def delete_gmail_credential(
await db.delete(credential)
await db.commit()
@router.get("/gmail/authorize-url", response_model=GmailAuthorizeResponse)
async def get_gmail_authorize_url(
redirect_uri: str,
current_user: User = Depends(get_current_active_user),
):
"""
Return a Google OAuth2 URL that grants this app Gmail API write permissions.
The URL requests the gmail.insert + gmail.labels scopes with
access_type=offline so a long-lived refresh token is issued.
prompt=consent forces Google to always issue a new refresh token even if
the user has authorised the app before.
"""
if not settings.GOOGLE_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth2 is not configured on this server.",
)
scope = " ".join(GMAIL_API_SCOPES)
url = (
"https://accounts.google.com/o/oauth2/v2/auth"
f"?client_id={settings.GOOGLE_CLIENT_ID}"
"&response_type=code"
f"&scope={scope}"
f"&redirect_uri={redirect_uri}"
"&access_type=offline"
"&prompt=consent"
)
return GmailAuthorizeResponse(authorization_url=url)
@router.post(
"/gmail/callback",
response_model=GmailCredentialResponse,
status_code=status.HTTP_201_CREATED,
)
async def gmail_oauth_callback(
callback_in: GmailCallbackRequest,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""
Exchange a Google authorization code for Gmail API tokens and persist them.
This is the backend half of the "Connect Gmail" one-click flow. The
frontend redirects the user to Google, Google sends a code back to the
frontend callback page, and the frontend posts that code here.
Token lifetime
--------------
* Access token : 1 hour. The google-auth library auto-refreshes it
during Celery tasks; the new value is written back to this row so the
next run doesn't need an extra refresh round-trip.
* Refresh token : does not expire unless the user revokes access or the
app credentials change. If revoked, ``is_valid`` is set to False by
the next Celery run that hits a 401, and the user must re-authorise.
"""
if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Google OAuth2 is not configured on this server.",
)
# Exchange code for tokens
async with httpx.AsyncClient() as client:
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": callback_in.code,
"client_id": settings.GOOGLE_CLIENT_ID,
"client_secret": settings.GOOGLE_CLIENT_SECRET,
"redirect_uri": callback_in.redirect_uri,
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
logger.error(f"Gmail token exchange failed: {token_resp.text}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code with Google.",
)
token_data = token_resp.json()
access_token: Optional[str] = token_data.get("access_token")
refresh_token: Optional[str] = token_data.get("refresh_token")
if not access_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google did not return an access token.",
)
# Fetch the Gmail email address to associate with this credential
async with httpx.AsyncClient() as client:
profile_resp = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
gmail_email = current_user.email # fallback
if profile_resp.status_code == 200:
gmail_email = profile_resp.json().get("email", current_user.email)
# Calculate token expiry (Google access tokens last 1 hour)
token_expiry = datetime.now(timezone.utc) + timedelta(
seconds=int(token_data.get("expires_in", 3600))
)
# Verify the credentials actually work with the Gmail API
gmail_service = GmailService(
access_token=access_token,
refresh_token=refresh_token,
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
)
is_valid = await gmail_service.verify_access()
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Obtained tokens but could not verify Gmail API access. "
"Ensure the gmail.insert scope was granted.",
)
# Persist tokens
result = await db.execute(
select(GmailCredential).where(GmailCredential.user_id == current_user.id)
)
existing = result.scalar_one_or_none()
encrypted_access = encrypt_credential(access_token)
encrypted_refresh = encrypt_credential(refresh_token) if refresh_token else None
if existing:
existing.gmail_email = gmail_email # type: ignore[assignment]
existing.encrypted_access_token = encrypted_access # type: ignore[assignment]
if encrypted_refresh:
existing.encrypted_refresh_token = encrypted_refresh # type: ignore[assignment]
existing.token_expiry = token_expiry # type: ignore[assignment]
existing.scopes = token_data.get("scope", "").split() # type: ignore[assignment]
existing.is_valid = True # type: ignore[assignment]
existing.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
await db.commit()
await db.refresh(existing)
return existing
else:
credential = GmailCredential(
user_id=current_user.id,
gmail_email=gmail_email,
encrypted_access_token=encrypted_access,
encrypted_refresh_token=encrypted_refresh,
token_expiry=token_expiry,
scopes=token_data.get("scope", "").split(),
is_valid=True,
last_verified_at=datetime.now(timezone.utc),
)
db.add(credential)
await db.commit()
await db.refresh(credential)
return credential
+103 -3
View File
@@ -1,12 +1,19 @@
"""User management endpoints"""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.database_models import User
from app.models.schemas import UserDetailResponse, UserUpdate
from app.core.security import encrypt_credential
from app.models.database_models import User, UserSmtpConfig
from app.models.schemas import (
UserDetailResponse,
UserUpdate,
UserSmtpConfigUpdate,
UserSmtpConfigResponse,
)
router = APIRouter()
@@ -34,3 +41,96 @@ async def update_current_user_profile(
await db.commit()
await db.refresh(current_user)
return current_user
@router.get("/smtp-config", response_model=UserSmtpConfigResponse)
async def get_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No SMTP configuration found. Save one first.",
)
return UserSmtpConfigResponse(
id=config.id, # type: ignore[arg-type]
user_id=config.user_id, # type: ignore[arg-type]
host=config.host, # type: ignore[arg-type]
port=config.port, # type: ignore[arg-type]
username=config.username, # type: ignore[arg-type]
use_tls=config.use_tls, # type: ignore[arg-type]
has_password=bool(config.encrypted_password),
created_at=config.created_at, # type: ignore[arg-type]
updated_at=config.updated_at, # type: ignore[arg-type]
)
@router.put("/smtp-config", response_model=UserSmtpConfigResponse)
async def upsert_smtp_config(
config_in: UserSmtpConfigUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Create or update the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if config:
config.host = config_in.host # type: ignore[assignment]
config.port = config_in.port # type: ignore[assignment]
config.username = config_in.username # type: ignore[assignment]
config.use_tls = config_in.use_tls # type: ignore[assignment]
if config_in.password is not None:
config.encrypted_password = encrypt_credential(config_in.password) # type: ignore[assignment]
else:
config = UserSmtpConfig(
user_id=current_user.id,
host=config_in.host,
port=config_in.port,
username=config_in.username,
encrypted_password=(
encrypt_credential(config_in.password) if config_in.password else ""
),
use_tls=config_in.use_tls,
)
db.add(config)
await db.commit()
await db.refresh(config)
return UserSmtpConfigResponse(
id=config.id, # type: ignore[arg-type]
user_id=config.user_id, # type: ignore[arg-type]
host=config.host, # type: ignore[arg-type]
port=config.port, # type: ignore[arg-type]
username=config.username, # type: ignore[arg-type]
use_tls=config.use_tls, # type: ignore[arg-type]
has_password=bool(config.encrypted_password),
created_at=config.created_at, # type: ignore[arg-type]
updated_at=config.updated_at, # type: ignore[arg-type]
)
@router.delete("/smtp-config", status_code=status.HTTP_204_NO_CONTENT)
async def delete_smtp_config(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Delete the current user's SMTP relay configuration"""
result = await db.execute(
select(UserSmtpConfig).where(UserSmtpConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
if config:
await db.delete(config)
await db.commit()
+32
View File
@@ -446,6 +446,38 @@ class AuditLog(Base):
)
class UserSmtpConfig(Base):
"""Per-user SMTP relay configuration for email forwarding fallback."""
__tablename__ = "user_smtp_configs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True
)
host = Column(String(255), nullable=False, default="smtp.gmail.com")
port = Column(Integer, nullable=False, default=587)
username = Column(String(255), nullable=False, default="")
encrypted_password = Column(Text, nullable=False, default="")
use_tls = Column(Boolean, default=True)
created_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
# Relationships
user = relationship("User", backref="smtp_config")
class DownloadedMessageId(Base):
"""
Tracks unique message IDs that have already been downloaded and forwarded.
+32
View File
@@ -339,3 +339,35 @@ class ProviderPreset(BaseModel):
class ProviderListResponse(BaseModel):
providers: List[ProviderPreset]
# User SMTP Config Schemas
class UserSmtpConfigBase(BaseModel):
host: str = "smtp.gmail.com"
port: int = Field(587, gt=0, lt=65536)
username: str = ""
use_tls: bool = True
class UserSmtpConfigUpdate(UserSmtpConfigBase):
password: Optional[str] = None # Only provided when changing the password
class UserSmtpConfigResponse(UserSmtpConfigBase):
id: int
user_id: int
has_password: bool # True if a password is stored (value is never returned)
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
# Gmail OAuth Schemas
class GmailAuthorizeResponse(BaseModel):
authorization_url: str
class GmailCallbackRequest(BaseModel):
code: str
redirect_uri: str
+23
View File
@@ -56,6 +56,7 @@ class GmailService:
client_id: Google OAuth2 client ID
client_secret: Google OAuth2 client secret
"""
self._initial_access_token = access_token
self.credentials = Credentials(
token=access_token,
refresh_token=refresh_token,
@@ -135,6 +136,7 @@ class GmailService:
f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
)
logger.error(error_msg)
# Surface 401 so callers can mark credentials as invalid
raise GmailInjectionError(error_msg)
except Exception as e:
error_msg = f"Failed to inject email into Gmail: {str(e)}"
@@ -180,3 +182,24 @@ class GmailService:
except Exception as e:
logger.error(f"Failed to get Gmail email address: {e}")
return None
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
"""
Return the current access token and expiry if the token was refreshed
since this service instance was created.
The google-auth library auto-refreshes the access token when an API
call is made with an expired token. Call this after inject_email() to
check whether a refresh happened and persist the new token.
Returns:
Dict with ``access_token`` and ``expiry`` (datetime | None), or
None if the token has not changed from the one passed to __init__.
"""
current_token = self.credentials.token
if current_token and current_token != self._initial_access_token:
return {
"access_token": current_token,
"expiry": self.credentials.expiry,
}
return None
+57 -4
View File
@@ -9,7 +9,7 @@ import logging
from app.workers.celery_app import celery_app
from app.core.database import async_session_maker
from app.core.security import decrypt_credential
from app.core.security import decrypt_credential, encrypt_credential
from app.models.database_models import (
MailAccount,
ProcessingRun,
@@ -18,12 +18,13 @@ from app.models.database_models import (
DeliveryMethod,
GmailCredential,
DownloadedMessageId,
UserSmtpConfig,
)
from app.services.mail_processor import MailProcessor
from app.services.gmail_service import GmailService
from app.services.config_service import ConfigService
from app.core.config import settings
from sqlalchemy import select, and_, delete
from sqlalchemy import select, delete
logger = logging.getLogger(__name__)
@@ -130,8 +131,24 @@ async def process_mail_account(account_id: int):
use_gmail_api = False # type: ignore[assignment]
if not use_gmail_api:
# Fall back to SMTP read config from DB with env fallback
smtp_config = await ConfigService.get_smtp_config(db=db)
# Fall back to SMTP check per-user config first, then global
user_smtp_result = await db.execute(
select(UserSmtpConfig).where(
UserSmtpConfig.user_id == account.user_id
)
)
user_smtp = user_smtp_result.scalar_one_or_none()
if user_smtp and user_smtp.username and user_smtp.encrypted_password:
smtp_config = {
"host": user_smtp.host,
"port": user_smtp.port,
"username": user_smtp.username,
"password": decrypt_credential(user_smtp.encrypted_password), # type: ignore[arg-type]
"use_tls": user_smtp.use_tls,
}
else:
smtp_config = await ConfigService.get_smtp_config(db=db)
if not smtp_config["username"] or not smtp_config["password"]:
logger.error(
@@ -144,6 +161,12 @@ async def process_mail_account(account_id: int):
successfully_forwarded_uids: list[str] = []
if len(emails) != len(new_uids):
logger.error(
f"emails/uids length mismatch ({len(emails)} vs {len(new_uids)}) "
f"for account {account.id}; truncating to shorter list"
)
for email_data, uid in zip(emails, new_uids):
try:
if use_gmail_api and gmail_service:
@@ -167,6 +190,23 @@ async def process_mail_account(account_id: int):
emails_failed += 1
except Exception as e:
error_str = str(e).lower()
# If Gmail returns 401/403 the refresh token was revoked
# mark credentials invalid so the user gets notified.
if (
use_gmail_api
and gmail_cred
and (
"401" in error_str
or "403" in error_str
or "invalid_grant" in error_str
)
):
gmail_cred.is_valid = False # type: ignore[assignment]
logger.warning(
f"Gmail credentials revoked for user {account.user_id}. "
"User must re-authorise."
)
logger.error(f"Error delivering email: {e}")
emails_failed += 1
@@ -180,6 +220,19 @@ async def process_mail_account(account_id: int):
)
)
# If Gmail API was used, persist any refreshed access token back to
# the DB so the next run doesn't need an extra token-refresh call.
if use_gmail_api and gmail_service and gmail_cred:
refreshed = gmail_service.get_refreshed_token()
if refreshed and refreshed["access_token"] != access_token:
gmail_cred.encrypted_access_token = encrypt_credential(refreshed["access_token"]) # type: ignore[assignment]
if refreshed.get("expiry"):
gmail_cred.token_expiry = refreshed["expiry"] # type: ignore[assignment]
gmail_cred.last_verified_at = datetime.now(timezone.utc) # type: ignore[assignment]
logger.info(
f"Persisted refreshed Gmail access token for user {account.user_id}"
)
# Update run
run.emails_forwarded = emails_forwarded # type: ignore[assignment]
run.emails_failed = emails_failed # type: ignore[assignment]