Files
gh-christianlouis-inboxconv…/backend/app/services/gmail_service.py
T
copilot-swe-agent[bot] b46fabd8cd feat: expand Gmail OAuth scopes, unify Google callback URL, store Gmail creds on login
- Add gmail.readonly to GMAIL_SCOPES (gmail_service) and GMAIL_API_SCOPES (providers)
  so users().getProfile() no longer returns 403 insufficientPermissions
- Consolidate Gmail scope list: GMAIL_SCOPES in gmail_service.py is the single
  source of truth; auth.py and providers.py now import and spread it
- Add include_granted_scopes=true to both Gmail and login authorize URLs so
  scope additions take effect for previously-connected users
- Add state=gmail_connect to the Gmail authorize URL; the shared /auth/callback
  page routes to gmailApi.saveCallback() when this state is present, otherwise
  falls through to the normal login flow
- Google Sign-In authorize URL now requests all six scopes (openid, email,
  profile + 3 Gmail scopes) with access_type=offline, prompt=consent, and
  include_granted_scopes=true
- POST /auth/google now stores Gmail credentials automatically after sign-in
  (non-fatal: login succeeds even if credential storage fails)
- Settings Connect Gmail button now uses /auth/callback instead of
  /auth/gmail-callback - only ONE redirect URI needed in Google Cloud Console
- URL-encode scope parameter in both authorize URL builders
- Update auth_service._register_google scope to include all Gmail scopes
- Update CHANGELOG.md and docs/TODO.md

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/d6e2de8b-088d-46f3-8127-137245c3ecdd
2026-03-25 19:53:46 +00:00

207 lines
6.6 KiB
Python

"""
Gmail API service for injecting emails directly into Gmail.
Uses the Gmail API's users.messages.insert() method to inject emails
into a user's Gmail account, preserving original headers and metadata.
This is preferred over SMTP forwarding as it doesn't modify the email.
"""
import asyncio
import base64
import logging
from typing import Optional, Dict, Any
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
logger = logging.getLogger(__name__)
# Gmail API scopes needed for email injection
GMAIL_SCOPES = [
"https://www.googleapis.com/auth/gmail.insert",
"https://www.googleapis.com/auth/gmail.labels",
"https://www.googleapis.com/auth/gmail.readonly",
]
class GmailInjectionError(Exception):
"""Raised when Gmail API injection fails"""
pass
class GmailService:
"""
Service for injecting emails into Gmail via the Gmail API.
Uses users.messages.insert() which places emails directly into
the user's mailbox without sending them through SMTP.
"""
def __init__(
self,
access_token: str,
refresh_token: Optional[str] = None,
token_uri: str = "https://oauth2.googleapis.com/token",
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
):
"""
Initialize Gmail service with OAuth2 credentials.
Args:
access_token: Valid OAuth2 access token
refresh_token: OAuth2 refresh token for automatic renewal
token_uri: OAuth2 token endpoint
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,
token_uri=token_uri,
client_id=client_id,
client_secret=client_secret,
scopes=GMAIL_SCOPES,
)
self._service = None
@property
def service(self):
"""Lazy-initialize the Gmail API service."""
if self._service is None:
self._service = build("gmail", "v1", credentials=self.credentials)
return self._service
async def inject_email(
self,
raw_email: bytes,
label_ids: Optional[list] = None,
source_account_name: Optional[str] = None,
) -> Dict[str, Any]:
"""
Inject a raw email into the user's Gmail account.
Uses users.messages.insert() to place the email directly
into the mailbox. The email appears as if it was received
normally, preserving all original headers.
Args:
raw_email: Raw email bytes (RFC 2822 format)
label_ids: Gmail label IDs to apply (defaults to ["INBOX"])
source_account_name: Optional name for logging
Returns:
Dict with message id and thread id
Raises:
GmailInjectionError: If injection fails
"""
if label_ids is None:
label_ids = ["INBOX"]
# Base64url encode the raw email
encoded_message = base64.urlsafe_b64encode(raw_email).decode("utf-8")
message_body = {
"raw": encoded_message,
"labelIds": label_ids,
}
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users()
.messages()
.insert(userId="me", body=message_body)
.execute(),
)
logger.info(
f"Injected email into Gmail: id={result.get('id')}"
f"{f' from {source_account_name}' if source_account_name else ''}"
)
return {
"message_id": result.get("id"),
"thread_id": result.get("threadId"),
"label_ids": result.get("labelIds", []),
}
except HttpError as e:
error_msg = (
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)}"
logger.error(error_msg)
raise GmailInjectionError(error_msg)
async def verify_access(self) -> bool:
"""
Verify that the Gmail API credentials are valid.
Returns:
True if credentials are valid and can access Gmail
"""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
email = result.get("emailAddress", "unknown")
logger.info(f"Gmail API access verified for: {email}")
return True
except Exception as e:
logger.error(f"Gmail API access verification failed: {e}")
return False
async def get_email_address(self) -> Optional[str]:
"""
Get the email address associated with the Gmail credentials.
Returns:
Email address string or None if unavailable
"""
loop = asyncio.get_event_loop()
try:
result = await loop.run_in_executor(
None,
lambda: self.service.users().getProfile(userId="me").execute(),
)
return result.get("emailAddress")
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