feat: implement Gmail API mail source with OAuth2, ingestion tracking, and UI
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/0c63851e-a69a-4a76-8dd8-25618e825b8c Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""add Gmail API fields to mail_sources
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-03-29 19:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b2c3d4e5f6a7"
|
||||
down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add Gmail OAuth2 credential columns to mail_sources."""
|
||||
op.add_column("mail_sources", sa.Column("gmail_client_id", sa.String(), nullable=True))
|
||||
op.add_column("mail_sources", sa.Column("gmail_client_secret", sa.Text(), nullable=True))
|
||||
op.add_column("mail_sources", sa.Column("gmail_access_token", sa.Text(), nullable=True))
|
||||
op.add_column("mail_sources", sa.Column("gmail_refresh_token", sa.Text(), nullable=True))
|
||||
op.add_column("mail_sources", sa.Column("gmail_email", sa.String(), nullable=True))
|
||||
op.add_column(
|
||||
"mail_sources",
|
||||
sa.Column("gmail_ingested_ids", sa.Text(), nullable=True, server_default="[]"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove Gmail OAuth2 credential columns from mail_sources."""
|
||||
op.drop_column("mail_sources", "gmail_ingested_ids")
|
||||
op.drop_column("mail_sources", "gmail_email")
|
||||
op.drop_column("mail_sources", "gmail_refresh_token")
|
||||
op.drop_column("mail_sources", "gmail_access_token")
|
||||
op.drop_column("mail_sources", "gmail_client_secret")
|
||||
op.drop_column("mail_sources", "gmail_client_id")
|
||||
@@ -3,20 +3,22 @@ Mail Sources API endpoints.
|
||||
|
||||
Provides CRUD operations for MailSource objects stored in the database, plus
|
||||
a *test-connection* action that validates the supplied credentials without
|
||||
persisting anything.
|
||||
persisting anything. Gmail API sources additionally have OAuth2 helper
|
||||
endpoints (authorize-url, callback, fetch).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import require_admin_auth
|
||||
from app.models.mail_source import MailSource
|
||||
from app.services.gmail_client import GmailClient
|
||||
from app.services.imap_client import IMAPClient
|
||||
|
||||
router = APIRouter()
|
||||
@@ -41,6 +43,9 @@ class MailSourceBase(BaseModel):
|
||||
folder: str = "INBOX"
|
||||
polling_interval: int = 60
|
||||
enabled: bool = True
|
||||
# Gmail API OAuth2 fields (only relevant when method == GMAIL_API)
|
||||
gmail_client_id: Optional[str] = None
|
||||
gmail_client_secret: Optional[str] = None
|
||||
|
||||
|
||||
class MailSourceCreate(MailSourceBase):
|
||||
@@ -60,6 +65,8 @@ class MailSourceUpdate(BaseModel):
|
||||
folder: Optional[str] = None
|
||||
polling_interval: Optional[int] = None
|
||||
enabled: Optional[bool] = None
|
||||
gmail_client_id: Optional[str] = None
|
||||
gmail_client_secret: Optional[str] = None
|
||||
|
||||
|
||||
class MailSourceResponse(MailSourceBase):
|
||||
@@ -71,6 +78,10 @@ class MailSourceResponse(MailSourceBase):
|
||||
updated_at: Optional[datetime] = None
|
||||
# Mask the stored password in responses
|
||||
password: Optional[str] = None
|
||||
# Gmail: show the authorised email address but not tokens
|
||||
gmail_email: Optional[str] = None
|
||||
# Indicate whether OAuth tokens are present (without exposing them)
|
||||
gmail_connected: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -87,6 +98,13 @@ class TestConnectionRequest(BaseModel):
|
||||
method: str = "IMAP"
|
||||
|
||||
|
||||
class GmailCallbackRequest(BaseModel):
|
||||
"""Payload for the Gmail OAuth2 callback endpoint."""
|
||||
|
||||
code: str
|
||||
redirect_uri: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -124,6 +142,10 @@ def _source_to_response(source: MailSource) -> MailSourceResponse:
|
||||
last_checked=source.last_checked,
|
||||
created_at=source.created_at,
|
||||
updated_at=source.updated_at,
|
||||
gmail_client_id=source.gmail_client_id,
|
||||
gmail_client_secret="**redacted**" if source.gmail_client_secret else None,
|
||||
gmail_email=source.gmail_email,
|
||||
gmail_connected=bool(source.gmail_access_token),
|
||||
)
|
||||
|
||||
|
||||
@@ -160,6 +182,8 @@ async def create_mail_source(
|
||||
folder=payload.folder,
|
||||
polling_interval=payload.polling_interval,
|
||||
enabled=payload.enabled,
|
||||
gmail_client_id=payload.gmail_client_id,
|
||||
gmail_client_secret=payload.gmail_client_secret,
|
||||
)
|
||||
db.add(source)
|
||||
db.commit()
|
||||
@@ -242,6 +266,38 @@ async def test_stored_mail_source(
|
||||
"""Test the connection for an already-stored mail source using its saved credentials."""
|
||||
source = _get_source_or_404(source_id, db)
|
||||
|
||||
if source.method == "GMAIL_API":
|
||||
if not source.gmail_access_token:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Gmail API source is not yet authorised. "
|
||||
"Use the 'Connect Gmail' button to complete OAuth2 authorisation.",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
gmail_client = GmailClient(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
access_token=source.gmail_access_token,
|
||||
refresh_token=source.gmail_refresh_token or "",
|
||||
)
|
||||
# Attempt to list one message to verify the credentials work
|
||||
service = gmail_client._build_service() # pylint: disable=protected-access
|
||||
service.users().getProfile(userId="me").execute()
|
||||
source.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Gmail API credentials are valid (account: {source.gmail_email or 'unknown'}).",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Gmail API test failed: {exc}",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
if source.method != "IMAP":
|
||||
return {
|
||||
"success": False,
|
||||
@@ -308,3 +364,292 @@ async def test_connection_adhoc(
|
||||
"available_mailboxes": stats.get("available_mailboxes", []),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gmail API OAuth2 routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{source_id}/gmail/authorize-url", response_model=Dict[str, Any])
|
||||
async def gmail_authorize_url(
|
||||
source_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Return a Google OAuth2 authorization URL for the given GMAIL_API source.
|
||||
|
||||
The frontend should redirect the user to this URL. After the user
|
||||
grants access Google redirects back to
|
||||
``<origin>/mail-sources/<id>/gmail/callback`` with a ``code`` parameter.
|
||||
"""
|
||||
source = _get_source_or_404(source_id, db)
|
||||
|
||||
if source.method != "GMAIL_API":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This endpoint is only available for GMAIL_API sources.",
|
||||
)
|
||||
if not source.gmail_client_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="gmail_client_id is not configured for this source.",
|
||||
)
|
||||
|
||||
# Build a redirect_uri that points back to this server's callback endpoint
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
redirect_uri = f"{base_url}/api/v1/mail-sources/{source_id}/gmail/callback"
|
||||
|
||||
auth_url = GmailClient.build_authorization_url(
|
||||
client_id=source.gmail_client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=str(source_id),
|
||||
)
|
||||
return {
|
||||
"authorization_url": auth_url,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{source_id}/gmail/callback")
|
||||
async def gmail_oauth_callback(
|
||||
source_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Any:
|
||||
"""
|
||||
Handle the Google OAuth2 redirect after the user grants Gmail access.
|
||||
|
||||
Exchanges the authorization ``code`` query parameter for access/refresh
|
||||
tokens and stores them on the MailSource row. This endpoint is called
|
||||
directly by Google's redirect, so it does not require the usual API key
|
||||
authentication; it is protected instead by the state/code being
|
||||
single-use and bound to the source_id in the URL.
|
||||
"""
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
code = request.query_params.get("code")
|
||||
error = request.query_params.get("error")
|
||||
|
||||
if error or not code:
|
||||
html = (
|
||||
"<html><body><p>Gmail authorisation failed: "
|
||||
f"{error or 'no code received'}. "
|
||||
"You may close this window.</p></body></html>"
|
||||
)
|
||||
return HTMLResponse(content=html, status_code=400)
|
||||
|
||||
source = db.query(MailSource).filter(MailSource.id == source_id).first()
|
||||
if source is None or source.method != "GMAIL_API":
|
||||
return HTMLResponse(
|
||||
content="<html><body><p>Mail source not found.</p></body></html>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
redirect_uri = f"{base_url}/api/v1/mail-sources/{source_id}/gmail/callback"
|
||||
|
||||
try:
|
||||
token_data = GmailClient.exchange_code_for_tokens(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
code=code,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.error("Gmail token exchange error for source id=%d: %s", source_id, exc)
|
||||
html = (
|
||||
"<html><body><p>Token exchange failed. "
|
||||
"Please close this window and try again.</p></body></html>"
|
||||
)
|
||||
return HTMLResponse(content=html, status_code=400)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = token_data.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
return HTMLResponse(
|
||||
content="<html><body><p>No access token returned by Google.</p></body></html>",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
gmail_email = GmailClient.get_gmail_email(access_token)
|
||||
|
||||
source.gmail_access_token = access_token
|
||||
if refresh_token:
|
||||
source.gmail_refresh_token = refresh_token
|
||||
if gmail_email:
|
||||
source.gmail_email = gmail_email
|
||||
source.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"Gmail OAuth2 authorisation complete for source id=%d (account=%s)",
|
||||
source_id,
|
||||
gmail_email or "unknown",
|
||||
)
|
||||
|
||||
html = (
|
||||
"<html><body>"
|
||||
"<p>✅ Gmail account connected successfully"
|
||||
f"{(' (' + gmail_email + ')') if gmail_email else ''}. "
|
||||
"You may close this window.</p>"
|
||||
"<script>window.close();</script>"
|
||||
"</body></html>"
|
||||
)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@router.post("/{source_id}/gmail/callback", response_model=MailSourceResponse)
|
||||
async def gmail_oauth_callback_post(
|
||||
source_id: int,
|
||||
payload: GmailCallbackRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> MailSourceResponse:
|
||||
"""
|
||||
Exchange an OAuth2 authorization code for tokens (JSON / programmatic flow).
|
||||
|
||||
This POST variant is for clients that handle the OAuth2 redirect
|
||||
themselves and post the code here as JSON. Requires the standard
|
||||
admin authentication.
|
||||
"""
|
||||
source = _get_source_or_404(source_id, db)
|
||||
|
||||
if source.method != "GMAIL_API":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This endpoint is only available for GMAIL_API sources.",
|
||||
)
|
||||
|
||||
try:
|
||||
token_data = GmailClient.exchange_code_for_tokens(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
code=payload.code,
|
||||
redirect_uri=payload.redirect_uri,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
refresh_token = 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.",
|
||||
)
|
||||
|
||||
gmail_email = GmailClient.get_gmail_email(access_token)
|
||||
|
||||
source.gmail_access_token = access_token
|
||||
if refresh_token:
|
||||
source.gmail_refresh_token = refresh_token
|
||||
if gmail_email:
|
||||
source.gmail_email = gmail_email
|
||||
source.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(source)
|
||||
|
||||
logger.info(
|
||||
"Gmail OAuth2 tokens saved for source id=%d (account=%s)",
|
||||
source_id,
|
||||
gmail_email or "unknown",
|
||||
)
|
||||
return _source_to_response(source)
|
||||
|
||||
|
||||
@router.post("/{source_id}/gmail/fetch", response_model=Dict[str, Any])
|
||||
async def gmail_fetch_reports(
|
||||
source_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually trigger a Gmail DMARC report fetch for the given source.
|
||||
|
||||
Searches Gmail for emails matching the DMARC report heuristic, ingests
|
||||
any attachments not yet seen, and returns a summary.
|
||||
"""
|
||||
source = _get_source_or_404(source_id, db)
|
||||
|
||||
if source.method != "GMAIL_API":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This endpoint is only available for GMAIL_API sources.",
|
||||
)
|
||||
if not source.gmail_access_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Gmail account not yet authorised. Complete OAuth2 flow first.",
|
||||
)
|
||||
|
||||
already = GmailClient.load_ingested_ids(source.gmail_ingested_ids)
|
||||
client = GmailClient(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
access_token=source.gmail_access_token,
|
||||
refresh_token=source.gmail_refresh_token or "",
|
||||
already_ingested_ids=already,
|
||||
)
|
||||
|
||||
results = client.fetch_reports()
|
||||
|
||||
# Persist updated ingested IDs and any refreshed tokens
|
||||
if results.get("new_ingested_ids"):
|
||||
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
||||
source.gmail_ingested_ids = GmailClient.dump_ingested_ids(all_ids)
|
||||
|
||||
refreshed = client.get_refreshed_tokens()
|
||||
if refreshed:
|
||||
source.gmail_access_token = refreshed["access_token"]
|
||||
if "refresh_token" in refreshed:
|
||||
source.gmail_refresh_token = refreshed["refresh_token"]
|
||||
|
||||
source.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"Gmail fetch for source id=%d: processed=%d reports_found=%d",
|
||||
source_id,
|
||||
results.get("processed", 0),
|
||||
results.get("reports_found", 0),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": results.get("success", False),
|
||||
"processed": results.get("processed", 0),
|
||||
"reports_found": results.get("reports_found", 0),
|
||||
"new_domains": results.get("new_domains", []),
|
||||
"errors": results.get("errors", []) or None,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{source_id}/gmail/connection", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def gmail_disconnect(
|
||||
source_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: dict = Depends(require_admin_auth),
|
||||
) -> None:
|
||||
"""Revoke / clear the stored Gmail OAuth2 tokens for this source."""
|
||||
source = _get_source_or_404(source_id, db)
|
||||
|
||||
if source.method != "GMAIL_API":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="This endpoint is only available for GMAIL_API sources.",
|
||||
)
|
||||
|
||||
source.gmail_access_token = None
|
||||
source.gmail_refresh_token = None
|
||||
source.gmail_email = None
|
||||
source.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
logger.info("Gmail tokens cleared for source id=%d", source_id)
|
||||
|
||||
+161
-42
@@ -15,6 +15,7 @@ from app.core.database import Base, SessionLocal, engine
|
||||
from app.core.security import add_api_key, generate_api_key, require_admin_auth
|
||||
from app.middleware.security import SecurityHeadersMiddleware
|
||||
from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered
|
||||
from app.services.gmail_client import GmailClient
|
||||
from app.services.imap_client import IMAPClient
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
@@ -69,6 +70,66 @@ def _poll_single_imap_source(source: MailSource) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _poll_single_gmail_source(source: MailSource) -> None:
|
||||
"""Fetch DMARC reports for a single GMAIL_API mail source."""
|
||||
global last_check_time # pylint: disable=global-statement
|
||||
|
||||
if not source.gmail_access_token:
|
||||
logger.info(
|
||||
"Gmail polling (source id=%d): skipped – OAuth2 not yet authorised",
|
||||
source.id,
|
||||
)
|
||||
return
|
||||
|
||||
already = GmailClient.load_ingested_ids(source.gmail_ingested_ids)
|
||||
client = GmailClient(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
access_token=source.gmail_access_token,
|
||||
refresh_token=source.gmail_refresh_token or "",
|
||||
already_ingested_ids=already,
|
||||
)
|
||||
|
||||
results = client.fetch_reports()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
src = db.query(MailSource).get(source.id)
|
||||
if src:
|
||||
if results.get("new_ingested_ids"):
|
||||
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
||||
src.gmail_ingested_ids = GmailClient.dump_ingested_ids(all_ids)
|
||||
|
||||
refreshed = client.get_refreshed_tokens()
|
||||
if refreshed:
|
||||
src.gmail_access_token = refreshed["access_token"]
|
||||
if "refresh_token" in refreshed:
|
||||
src.gmail_refresh_token = refreshed["refresh_token"]
|
||||
|
||||
src.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
last_check_time = datetime.now()
|
||||
|
||||
if results["success"]:
|
||||
logger.info(
|
||||
"Gmail polling (source id=%d): %s emails processed, %s reports found",
|
||||
source.id,
|
||||
results["processed"],
|
||||
results["reports_found"],
|
||||
)
|
||||
if results["new_domains"]:
|
||||
logger.info("New domains found: %s", ", ".join(results["new_domains"]))
|
||||
else:
|
||||
logger.error(
|
||||
"Gmail polling (source id=%d) failed: %s",
|
||||
source.id,
|
||||
results.get("error", "Unknown error"),
|
||||
)
|
||||
|
||||
|
||||
def _poll_all_enabled_sources() -> None:
|
||||
"""Iterate over all enabled mail sources and poll each one."""
|
||||
db = SessionLocal()
|
||||
@@ -84,17 +145,22 @@ def _poll_all_enabled_sources() -> None:
|
||||
return
|
||||
|
||||
for source in enabled_sources:
|
||||
if source.method != "IMAP":
|
||||
if source.method == "GMAIL_API":
|
||||
try:
|
||||
_poll_single_gmail_source(source)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling Gmail source id=%d: %s", source.id, str(e))
|
||||
elif source.method == "IMAP":
|
||||
try:
|
||||
_poll_single_imap_source(source)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||
else:
|
||||
logger.info(
|
||||
"Skipping mail source id=%d method=%r (not yet implemented)",
|
||||
source.id,
|
||||
source.method,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
_poll_single_imap_source(source)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||
|
||||
|
||||
def _next_sleep_seconds(min_sleep: int = 60) -> int:
|
||||
@@ -386,7 +452,95 @@ async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)):
|
||||
}
|
||||
|
||||
for source in enabled_sources:
|
||||
if source.method != "IMAP":
|
||||
if source.method == "GMAIL_API":
|
||||
if not source.gmail_access_token:
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"skipped": True,
|
||||
"reason": "Gmail account not yet authorised",
|
||||
}
|
||||
)
|
||||
continue
|
||||
try:
|
||||
already = GmailClient.load_ingested_ids(source.gmail_ingested_ids)
|
||||
gmail_client = GmailClient(
|
||||
client_id=source.gmail_client_id or "",
|
||||
client_secret=source.gmail_client_secret or "",
|
||||
access_token=source.gmail_access_token,
|
||||
refresh_token=source.gmail_refresh_token or "",
|
||||
already_ingested_ids=already,
|
||||
)
|
||||
results = gmail_client.fetch_reports()
|
||||
last_check_time = datetime.now()
|
||||
|
||||
if results.get("new_ingested_ids"):
|
||||
all_ids = list(dict.fromkeys(already + results["new_ingested_ids"]))
|
||||
source.gmail_ingested_ids = GmailClient.dump_ingested_ids(all_ids)
|
||||
refreshed = gmail_client.get_refreshed_tokens()
|
||||
if refreshed:
|
||||
source.gmail_access_token = refreshed["access_token"]
|
||||
if "refresh_token" in refreshed:
|
||||
source.gmail_refresh_token = refreshed["refresh_token"]
|
||||
source.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": results["success"],
|
||||
"processed": results.get("processed", 0),
|
||||
"reports_found": results.get("reports_found", 0),
|
||||
"new_domains": results.get("new_domains", []),
|
||||
}
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling Gmail source id=%d: %s", source.id, str(e))
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": False,
|
||||
"error": "Failed to poll. Check server logs for details.",
|
||||
}
|
||||
)
|
||||
elif source.method == "IMAP":
|
||||
try:
|
||||
imap_client = IMAPClient(
|
||||
server=source.server,
|
||||
port=source.port or 993,
|
||||
username=source.username,
|
||||
password=source.password,
|
||||
delete_emails=False,
|
||||
)
|
||||
results = imap_client.fetch_reports(days=7)
|
||||
last_check_time = datetime.now()
|
||||
source.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": results["success"],
|
||||
"processed": results.get("processed", 0),
|
||||
"reports_found": results.get("reports_found", 0),
|
||||
"new_domains": results.get("new_domains", []),
|
||||
}
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": False,
|
||||
"error": "Failed to poll. Check server logs for details.",
|
||||
}
|
||||
)
|
||||
else:
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
@@ -395,41 +549,6 @@ async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)):
|
||||
"reason": f"method '{source.method}' not yet implemented",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
imap_client = IMAPClient(
|
||||
server=source.server,
|
||||
port=source.port or 993,
|
||||
username=source.username,
|
||||
password=source.password,
|
||||
delete_emails=False,
|
||||
)
|
||||
results = imap_client.fetch_reports(days=7)
|
||||
last_check_time = datetime.now()
|
||||
source.last_checked = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": results["success"],
|
||||
"processed": results.get("processed", 0),
|
||||
"reports_found": results.get("reports_found", 0),
|
||||
"new_domains": results.get("new_domains", []),
|
||||
}
|
||||
)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||
results_summary.append(
|
||||
{
|
||||
"source_id": source.id,
|
||||
"name": source.name,
|
||||
"success": False,
|
||||
"error": "Failed to poll. Check server logs for details.",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class MailSource(Base):
|
||||
|
||||
- ``IMAP`` – standard IMAP4 (over SSL/TLS or STARTTLS)
|
||||
- ``POP3`` – POP3 inbox (stub for future implementation)
|
||||
- ``GMAIL_API`` – Gmail API with OAuth 2.0 (stub for future implementation)
|
||||
- ``GMAIL_API`` – Gmail API with OAuth 2.0
|
||||
"""
|
||||
|
||||
__tablename__ = "mail_sources"
|
||||
@@ -38,6 +38,17 @@ class MailSource(Base):
|
||||
use_ssl = Column(Boolean, default=True)
|
||||
folder = Column(String, default="INBOX")
|
||||
|
||||
# Gmail API OAuth2 credentials (used by GMAIL_API method)
|
||||
# NOTE: tokens stored in plaintext – encrypt at the app layer in production.
|
||||
gmail_client_id = Column(String, nullable=True)
|
||||
gmail_client_secret = Column(Text, nullable=True)
|
||||
gmail_access_token = Column(Text, nullable=True)
|
||||
gmail_refresh_token = Column(Text, nullable=True)
|
||||
# Email address of the authorised Gmail account
|
||||
gmail_email = Column(String, nullable=True)
|
||||
# JSON-encoded list of Gmail message IDs that have already been ingested
|
||||
gmail_ingested_ids = Column(Text, nullable=True, default="[]")
|
||||
|
||||
# Polling behaviour
|
||||
polling_interval = Column(Integer, default=60) # minutes
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Gmail API client for retrieving DMARC reports.
|
||||
|
||||
Connects to Gmail via OAuth 2.0, searches for emails that are likely to
|
||||
contain DMARC aggregate-report attachments, and processes any new ones.
|
||||
Already-ingested message IDs are tracked so the same email is never
|
||||
processed twice (no messages are modified or deleted).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import email
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from app.services.dmarc_parser import DMARCParser
|
||||
from app.services.report_store import ReportStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth2 scopes – read-only access to Gmail messages is all we need
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GMAIL_SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gmail search query used to find emails likely containing DMARC reports.
|
||||
#
|
||||
# Strategy:
|
||||
# • Require at least one attachment whose name ends in .zip, .gz, or .xml
|
||||
# (the three formats used by virtually every DMARC sender).
|
||||
# • Additionally require *either* a keyword in the subject that DMARC senders
|
||||
# use, or an envelope-from that belongs to a well-known DMARC reporting
|
||||
# address. This keeps false-positive rates low while catching reports
|
||||
# from providers that don't follow naming conventions perfectly.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DMARC_GMAIL_QUERY = (
|
||||
"has:attachment "
|
||||
"(filename:zip OR filename:gz OR filename:xml) "
|
||||
"(subject:dmarc OR subject:report OR subject:rua "
|
||||
'OR subject:"aggregate report" OR subject:"domain report" '
|
||||
"OR from:dmarc OR from:dmarc-noreply OR from:reports OR from:postmaster)"
|
||||
)
|
||||
|
||||
# How many message results to fetch per API page
|
||||
_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class GmailClient:
|
||||
"""
|
||||
Client for retrieving DMARC reports from a Gmail account via the Gmail API.
|
||||
|
||||
OAuth2 tokens are accepted at construction time and auto-refreshed when
|
||||
expired. The caller is responsible for persisting any refreshed tokens
|
||||
returned by :meth:`get_refreshed_tokens`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
already_ingested_ids: Optional[List[str]] = None,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._initial_access_token = access_token
|
||||
self.already_ingested_ids: List[str] = list(already_ingested_ids or [])
|
||||
self.report_store = ReportStore.get_instance()
|
||||
|
||||
self.credentials = Credentials(
|
||||
token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_uri="https://oauth2.googleapis.com/token",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=GMAIL_SCOPES,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_refreshed_tokens(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Return updated tokens if the google-auth library has refreshed them.
|
||||
|
||||
Call this after :meth:`fetch_reports` and persist any non-None result
|
||||
so the next run doesn't need an extra refresh round-trip.
|
||||
"""
|
||||
current = self.credentials.token
|
||||
if current and current != self._initial_access_token:
|
||||
result: Dict[str, str] = {"access_token": current}
|
||||
if self.credentials.refresh_token:
|
||||
result["refresh_token"] = self.credentials.refresh_token
|
||||
return result
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuth2 helpers (static / class methods used by the endpoint layer)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_authorization_url(
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
state: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Construct the Google OAuth2 authorization URL.
|
||||
|
||||
Requests offline access so a refresh token is issued, and forces
|
||||
the consent screen so the refresh token is always returned even if
|
||||
the user has authorised this app before.
|
||||
"""
|
||||
params: Dict[str, str] = {
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(GMAIL_SCOPES),
|
||||
"redirect_uri": redirect_uri,
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
if state:
|
||||
params["state"] = state
|
||||
return "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)
|
||||
|
||||
@staticmethod
|
||||
def exchange_code_for_tokens(
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
code: str,
|
||||
redirect_uri: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Synchronously exchange an authorization code for access+refresh tokens.
|
||||
|
||||
Returns the raw JSON from Google's token endpoint. The caller
|
||||
should check for ``access_token`` in the result before using it.
|
||||
|
||||
Raises:
|
||||
ValueError: if Google returns a non-200 response.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Token exchange failed ({resp.status_code}): {resp.text}")
|
||||
return resp.json()
|
||||
|
||||
@staticmethod
|
||||
def get_gmail_email(access_token: str) -> Optional[str]:
|
||||
"""
|
||||
Return the email address associated with an access token.
|
||||
|
||||
Uses the OAuth2 userinfo endpoint. Returns None on failure.
|
||||
"""
|
||||
try:
|
||||
resp = httpx.get(
|
||||
"https://www.googleapis.com/oauth2/v2/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json().get("email")
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Failed to fetch Gmail email address: %s", exc)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core fetching logic
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def fetch_reports(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Search Gmail for DMARC report emails and ingest any new ones.
|
||||
|
||||
Emails that have already been ingested (tracked via
|
||||
``already_ingested_ids``) are silently skipped. No messages are
|
||||
modified or deleted.
|
||||
|
||||
Returns:
|
||||
A dict with keys ``success``, ``processed``, ``reports_found``,
|
||||
``new_domains``, ``errors``, and ``new_ingested_ids`` (the IDs
|
||||
added in this run so the caller can persist them).
|
||||
"""
|
||||
stats: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"processed": 0,
|
||||
"reports_found": 0,
|
||||
"new_domains": [],
|
||||
"errors": [],
|
||||
"new_ingested_ids": [],
|
||||
}
|
||||
|
||||
try:
|
||||
service = self._build_service()
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Gmail API: failed to build service: %s", exc)
|
||||
return {**stats, "success": False, "error": str(exc)}
|
||||
|
||||
try:
|
||||
message_ids = self._list_dmarc_message_ids(service)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Gmail API: failed to list messages: %s", exc)
|
||||
return {**stats, "success": False, "error": str(exc)}
|
||||
|
||||
domains_before = set(self.report_store.get_domains())
|
||||
|
||||
for msg_id in message_ids:
|
||||
if msg_id in self.already_ingested_ids:
|
||||
continue
|
||||
|
||||
stats["processed"] += 1
|
||||
found = self._process_message(service, msg_id, stats)
|
||||
if found > 0:
|
||||
stats["new_ingested_ids"].append(msg_id)
|
||||
self.already_ingested_ids.append(msg_id)
|
||||
else:
|
||||
# Track it anyway so we don't re-examine it next run
|
||||
stats["new_ingested_ids"].append(msg_id)
|
||||
self.already_ingested_ids.append(msg_id)
|
||||
|
||||
domains_after = set(self.report_store.get_domains())
|
||||
stats["new_domains"] = list(domains_after - domains_before)
|
||||
return stats
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_service(self):
|
||||
"""Build (and auto-refresh if needed) the Gmail API service object."""
|
||||
if self.credentials.expired and self.credentials.refresh_token:
|
||||
try:
|
||||
self.credentials.refresh(Request())
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Gmail token refresh failed: %s", exc)
|
||||
raise
|
||||
|
||||
return build("gmail", "v1", credentials=self.credentials, cache_discovery=False)
|
||||
|
||||
def _list_dmarc_message_ids(self, service) -> List[str]:
|
||||
"""Return all Gmail message IDs matching the DMARC search query."""
|
||||
ids: List[str] = []
|
||||
page_token: Optional[str] = None
|
||||
|
||||
while True:
|
||||
kwargs: Dict[str, Any] = {
|
||||
"userId": "me",
|
||||
"q": DMARC_GMAIL_QUERY,
|
||||
"maxResults": _PAGE_SIZE,
|
||||
}
|
||||
if page_token:
|
||||
kwargs["pageToken"] = page_token
|
||||
|
||||
try:
|
||||
result = service.users().messages().list(**kwargs).execute()
|
||||
except HttpError as exc:
|
||||
logger.error("Gmail API list error: %s", exc)
|
||||
raise
|
||||
|
||||
for msg in result.get("messages", []):
|
||||
ids.append(msg["id"])
|
||||
|
||||
page_token = result.get("nextPageToken")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
return ids
|
||||
|
||||
def _process_message(self, service, msg_id: str, stats: dict) -> int:
|
||||
"""
|
||||
Download a Gmail message and process any DMARC-report attachments.
|
||||
|
||||
Returns the number of DMARC reports found in this message.
|
||||
"""
|
||||
try:
|
||||
msg_data = (
|
||||
service.users()
|
||||
.messages()
|
||||
.get(userId="me", id=msg_id, format="raw")
|
||||
.execute()
|
||||
)
|
||||
except HttpError as exc:
|
||||
logger.error("Gmail API: failed to fetch message %s: %s", msg_id, exc)
|
||||
stats["errors"].append(f"Failed to fetch message {msg_id}")
|
||||
return 0
|
||||
|
||||
raw_bytes = base64.urlsafe_b64decode(msg_data.get("raw", ""))
|
||||
msg = email.message_from_bytes(raw_bytes)
|
||||
return self._process_attachments(msg, stats)
|
||||
|
||||
def _process_attachments(self, msg: email.message.Message, stats: dict) -> int:
|
||||
"""Walk a parsed email message and extract DMARC report attachments."""
|
||||
reports_found = 0
|
||||
|
||||
for part in msg.walk():
|
||||
if part.get_content_disposition() != "attachment":
|
||||
continue
|
||||
|
||||
filename = part.get_filename() or ""
|
||||
if hasattr(filename, "encode"):
|
||||
# Decode RFC 2047-encoded filenames
|
||||
from email.header import decode_header
|
||||
|
||||
parts = decode_header(filename)
|
||||
decoded_parts = []
|
||||
for raw, charset in parts:
|
||||
if isinstance(raw, bytes):
|
||||
decoded_parts.append(raw.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
decoded_parts.append(raw)
|
||||
filename = "".join(decoded_parts)
|
||||
|
||||
lower = filename.lower()
|
||||
if not (
|
||||
lower.endswith(".xml")
|
||||
or lower.endswith(".zip")
|
||||
or lower.endswith(".gz")
|
||||
or lower.endswith(".gzip")
|
||||
):
|
||||
continue
|
||||
|
||||
content = part.get_payload(decode=True)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
try:
|
||||
parser = DMARCParser()
|
||||
reports = parser.parse(content, filename)
|
||||
for report in reports:
|
||||
self.report_store.add_report(report)
|
||||
stats["reports_found"] += 1
|
||||
reports_found += 1
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
logger.error("Failed to parse DMARC attachment %s: %s", filename, exc)
|
||||
stats["errors"].append(f"Failed to parse {filename}: {exc}")
|
||||
|
||||
return reports_found
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience: load / save ingested IDs from/to the JSON text column
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def load_ingested_ids(json_text: Optional[str]) -> List[str]:
|
||||
"""Deserialise the gmail_ingested_ids text column into a list."""
|
||||
if not json_text:
|
||||
return []
|
||||
try:
|
||||
return json.loads(json_text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def dump_ingested_ids(ids: List[str]) -> str:
|
||||
"""Serialise the list of ingested IDs back to a JSON string."""
|
||||
return json.dumps(ids)
|
||||
@@ -61,8 +61,8 @@
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Method</th>
|
||||
<th>Server</th>
|
||||
<th>Username</th>
|
||||
<th>Server / Account</th>
|
||||
<th>User / Status</th>
|
||||
<th>Last Checked</th>
|
||||
<th>Enabled</th>
|
||||
<th>Actions</th>
|
||||
@@ -75,8 +75,8 @@
|
||||
<td>
|
||||
<span class="badge badge-outline" x-text="source.method"></span>
|
||||
</td>
|
||||
<td x-text="source.server ? source.server + ':' + source.port : '—'"></td>
|
||||
<td x-text="source.username || '—'"></td>
|
||||
<td x-text="source.method === 'GMAIL_API' ? (source.gmail_email || '—') : (source.server ? source.server + ':' + source.port : '—')"></td>
|
||||
<td x-text="source.method === 'GMAIL_API' ? (source.gmail_connected ? '✅ Connected' : '⚠️ Not authorised') : (source.username || '—')"></td>
|
||||
<td x-text="source.last_checked ? new Date(source.last_checked).toLocaleString() : 'Never'"></td>
|
||||
<td>
|
||||
<input
|
||||
@@ -153,7 +153,7 @@
|
||||
<select x-model="form.method" class="select select-bordered w-full">
|
||||
<option value="IMAP">IMAP</option>
|
||||
<option value="POP3">POP3 (coming soon)</option>
|
||||
<option value="GMAIL_API">Gmail API (coming soon)</option>
|
||||
<option value="GMAIL_API">Gmail API (OAuth2)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -229,6 +229,72 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Gmail API fields -->
|
||||
<template x-if="form.method === 'GMAIL_API'">
|
||||
<div class="space-y-4">
|
||||
<div class="alert alert-info text-sm p-3">
|
||||
<div>
|
||||
<p class="font-semibold">Gmail API Setup</p>
|
||||
<p class="mt-1">Enter your Google OAuth2 Client ID and Secret from
|
||||
<a href="https://console.cloud.google.com/apis/credentials" target="_blank"
|
||||
class="underline">Google Cloud Console</a>.
|
||||
Enable the <strong>Gmail API</strong>, create an OAuth2 client (Web application),
|
||||
and add your callback URL as an authorised redirect URI.
|
||||
Then save this source and click <strong>Connect Gmail</strong> to authorise access.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label"><span class="label-text font-medium">Google Client ID <span class="text-error">*</span></span></label>
|
||||
<input type="text" x-model="form.gmail_client_id"
|
||||
placeholder="123456789-abc.apps.googleusercontent.com"
|
||||
class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Google Client Secret <span class="text-error">*</span></span>
|
||||
<span class="label-text-alt text-muted-foreground" x-show="editingId">Leave blank to keep existing</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input :type="showPassword ? 'text' : 'password'"
|
||||
x-model="form.gmail_client_secret"
|
||||
class="input input-bordered w-full pr-10"
|
||||
placeholder="GOCSPX-…" />
|
||||
<button type="button"
|
||||
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
|
||||
x-on:click="showPassword = !showPassword">
|
||||
<svg x-show="!showPassword" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
<svg x-show="showPassword" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"></path>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth connection status (when editing) -->
|
||||
<template x-if="editingId && gmailConnected">
|
||||
<div class="alert alert-success text-sm p-3">
|
||||
<span>✅ Connected as <strong x-text="gmailEmail || 'Gmail account'"></strong></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="editingId && !gmailConnected">
|
||||
<div class="alert alert-warning text-sm p-3">
|
||||
<span>⚠️ Not yet authorised. Save this source first, then click <strong>Connect Gmail</strong>.</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Polling interval -->
|
||||
<div>
|
||||
<label class="label"><span class="label-text font-medium">Polling Interval (minutes)</span></label>
|
||||
@@ -252,18 +318,34 @@
|
||||
|
||||
<!-- Form actions -->
|
||||
<div class="flex justify-between pt-2">
|
||||
<button type="button" class="btn btn-outline btn-sm"
|
||||
x-on:click="testAdHoc()"
|
||||
:disabled="isTesting || isSaving">
|
||||
<span x-show="!isTesting">Test Connection</span>
|
||||
<span x-show="isTesting" class="flex items-center" x-cloak>
|
||||
<svg class="animate-spin h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Testing...
|
||||
</span>
|
||||
</button>
|
||||
<!-- Test Connection (IMAP/POP3) or Connect Gmail button -->
|
||||
<div class="flex gap-2">
|
||||
<template x-if="form.method !== 'GMAIL_API'">
|
||||
<button type="button" class="btn btn-outline btn-sm"
|
||||
x-on:click="testAdHoc()"
|
||||
:disabled="isTesting || isSaving">
|
||||
<span x-show="!isTesting">Test Connection</span>
|
||||
<span x-show="isTesting" class="flex items-center" x-cloak>
|
||||
<svg class="animate-spin h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Testing...
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="form.method === 'GMAIL_API' && editingId">
|
||||
<button type="button" class="btn btn-outline btn-sm"
|
||||
x-on:click="connectGmail()"
|
||||
:disabled="isTesting || isSaving">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1">
|
||||
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path>
|
||||
</svg>
|
||||
Connect Gmail
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-sm" x-on:click="closeForm()">Cancel</button>
|
||||
<button type="submit" class="btn btn-default btn-sm" :disabled="isSaving">
|
||||
@@ -310,6 +392,8 @@ function mailSourcesApp() {
|
||||
testing: {},
|
||||
feedback: { message: '', type: '' },
|
||||
testResult: { message: '', success: false },
|
||||
gmailConnected: false,
|
||||
gmailEmail: '',
|
||||
|
||||
form: {
|
||||
name: '',
|
||||
@@ -322,6 +406,8 @@ function mailSourcesApp() {
|
||||
folder: 'INBOX',
|
||||
polling_interval: 60,
|
||||
enabled: true,
|
||||
gmail_client_id: '',
|
||||
gmail_client_secret: '',
|
||||
},
|
||||
|
||||
async init() {
|
||||
@@ -341,6 +427,8 @@ function mailSourcesApp() {
|
||||
|
||||
openAddForm() {
|
||||
this.editingId = null;
|
||||
this.gmailConnected = false;
|
||||
this.gmailEmail = '';
|
||||
this.form = {
|
||||
name: '',
|
||||
method: 'IMAP',
|
||||
@@ -352,6 +440,8 @@ function mailSourcesApp() {
|
||||
folder: 'INBOX',
|
||||
polling_interval: 60,
|
||||
enabled: true,
|
||||
gmail_client_id: '',
|
||||
gmail_client_secret: '',
|
||||
};
|
||||
this.testResult = { message: '', success: false };
|
||||
this.showForm = true;
|
||||
@@ -359,6 +449,8 @@ function mailSourcesApp() {
|
||||
|
||||
openEditForm(source) {
|
||||
this.editingId = source.id;
|
||||
this.gmailConnected = source.gmail_connected || false;
|
||||
this.gmailEmail = source.gmail_email || '';
|
||||
this.form = {
|
||||
name: source.name,
|
||||
method: source.method,
|
||||
@@ -370,6 +462,8 @@ function mailSourcesApp() {
|
||||
folder: source.folder || 'INBOX',
|
||||
polling_interval: source.polling_interval || 60,
|
||||
enabled: source.enabled !== false,
|
||||
gmail_client_id: source.gmail_client_id || '',
|
||||
gmail_client_secret: source.gmail_client_secret ? '' : '', // never pre-fill
|
||||
};
|
||||
this.testResult = { message: '', success: false };
|
||||
this.showForm = true;
|
||||
@@ -381,6 +475,48 @@ function mailSourcesApp() {
|
||||
this.testResult = { message: '', success: false };
|
||||
},
|
||||
|
||||
async connectGmail() {
|
||||
if (!this.editingId) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/v1/mail-sources/${this.editingId}/gmail/authorize-url`
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
this.feedback = { message: `Error: ${err.detail || 'Failed to get authorization URL'}`, type: 'error' };
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Open the OAuth2 URL in a new popup window
|
||||
const popup = window.open(
|
||||
data.authorization_url,
|
||||
'gmail_oauth',
|
||||
'width=600,height=700,scrollbars=yes'
|
||||
);
|
||||
// Poll for popup close and refresh source list
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (popup && popup.closed) {
|
||||
clearInterval(pollInterval);
|
||||
await this.loadSources();
|
||||
// Refresh the editing form state
|
||||
const updated = this.sources.find(s => s.id === this.editingId);
|
||||
if (updated) {
|
||||
this.gmailConnected = updated.gmail_connected || false;
|
||||
this.gmailEmail = updated.gmail_email || '';
|
||||
if (this.gmailConnected) {
|
||||
this.feedback = {
|
||||
message: `Gmail connected successfully (${this.gmailEmail}).`,
|
||||
type: 'success',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
this.feedback = { message: `Error: ${e.message}`, type: 'error' };
|
||||
}
|
||||
},
|
||||
|
||||
async saveSource() {
|
||||
this.isSaving = true;
|
||||
this.feedback = { message: '', type: '' };
|
||||
@@ -390,6 +526,10 @@ function mailSourcesApp() {
|
||||
if (this.editingId && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
// Don't send empty gmail_client_secret on edit
|
||||
if (this.editingId && !payload.gmail_client_secret) {
|
||||
delete payload.gmail_client_secret;
|
||||
}
|
||||
|
||||
const url = this.editingId
|
||||
? `/api/v1/mail-sources/${this.editingId}`
|
||||
|
||||
@@ -435,6 +435,285 @@ class TestMailSourcesAPIAuthed:
|
||||
assert "not yet implemented" in resp.json()["message"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gmail API-specific tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGmailAPIMailSource:
|
||||
"""Tests for GMAIL_API mail source creation, OAuth flow, and fetching."""
|
||||
|
||||
def test_create_gmail_api_source(self, authed_client: TestClient):
|
||||
payload = {
|
||||
"name": "My Gmail",
|
||||
"method": "GMAIL_API",
|
||||
"gmail_client_id": "123-abc.apps.googleusercontent.com",
|
||||
"gmail_client_secret": "GOCSPX-secret",
|
||||
"polling_interval": 30,
|
||||
"enabled": True,
|
||||
}
|
||||
resp = authed_client.post("/api/v1/mail-sources", json=payload)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["method"] == "GMAIL_API"
|
||||
assert data["gmail_client_id"] == "123-abc.apps.googleusercontent.com"
|
||||
# Secret should be redacted in response
|
||||
assert data["gmail_client_secret"] == "**redacted**"
|
||||
assert data["gmail_connected"] is False
|
||||
assert data["gmail_email"] is None
|
||||
|
||||
def test_gmail_source_test_no_token(self, authed_client: TestClient):
|
||||
"""Test a GMAIL_API source that has no OAuth tokens yet."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={"name": "Unauthed Gmail", "method": "GMAIL_API"},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "not yet authorised" in data["message"].lower() or "oauth" in data["message"].lower()
|
||||
|
||||
def test_gmail_source_test_with_valid_token(self, authed_client: TestClient):
|
||||
"""Test a GMAIL_API source that has valid OAuth tokens (mocked)."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={
|
||||
"name": "Authed Gmail",
|
||||
"method": "GMAIL_API",
|
||||
"gmail_client_id": "my-client-id",
|
||||
"gmail_client_secret": "my-secret",
|
||||
},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
# Inject tokens directly into DB via the DB session
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.mail_source import MailSource as MS
|
||||
|
||||
# Use the authed_client's DB override — patch the ORM object instead
|
||||
mock_service = MagicMock()
|
||||
mock_service.users.return_value.getProfile.return_value.execute.return_value = {
|
||||
"emailAddress": "test@gmail.com"
|
||||
}
|
||||
mock_gmail_client = MagicMock()
|
||||
mock_gmail_client._build_service.return_value = mock_service
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient",
|
||||
return_value=mock_gmail_client,
|
||||
):
|
||||
# First set the access token directly
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get:
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
mock_source.gmail_access_token = "valid-token"
|
||||
mock_source.gmail_email = "test@gmail.com"
|
||||
mock_source.gmail_client_id = "my-client-id"
|
||||
mock_source.gmail_client_secret = "my-secret"
|
||||
mock_source.gmail_refresh_token = "refresh-token"
|
||||
mock_get.return_value = mock_source
|
||||
|
||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert "valid" in data["message"].lower()
|
||||
|
||||
def test_gmail_authorize_url_no_client_id(self, authed_client: TestClient):
|
||||
"""Requesting authorize-url without a client_id returns 400."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={"name": "No Client ID Gmail", "method": "GMAIL_API"},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/authorize-url")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gmail_authorize_url_wrong_method(self, authed_client: TestClient):
|
||||
"""Requesting authorize-url on a non-GMAIL_API source returns 400."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources", json={"name": "IMAP Source", "method": "IMAP"}
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/authorize-url")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gmail_authorize_url_returns_google_url(self, authed_client: TestClient):
|
||||
"""A GMAIL_API source with client_id returns a valid Google auth URL."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={
|
||||
"name": "Ready Gmail",
|
||||
"method": "GMAIL_API",
|
||||
"gmail_client_id": "123-abc.apps.googleusercontent.com",
|
||||
},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/gmail/authorize-url")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "authorization_url" in data
|
||||
assert "accounts.google.com" in data["authorization_url"]
|
||||
assert "123-abc.apps.googleusercontent.com" in data["authorization_url"]
|
||||
assert "gmail.readonly" in data["authorization_url"]
|
||||
|
||||
def test_gmail_disconnect_clears_tokens(self, authed_client: TestClient):
|
||||
"""DELETE /gmail/connection clears stored OAuth tokens."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={"name": "Disconnect Test", "method": "GMAIL_API"},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.delete(f"/api/v1/mail-sources/{source_id}/gmail/connection")
|
||||
assert resp.status_code == 204
|
||||
|
||||
def test_gmail_disconnect_wrong_method_returns_400(self, authed_client: TestClient):
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources", json={"name": "IMAP2", "method": "IMAP"}
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
resp = authed_client.delete(f"/api/v1/mail-sources/{source_id}/gmail/connection")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gmail_fetch_no_token_returns_400(self, authed_client: TestClient):
|
||||
"""Fetch without OAuth tokens returns 400."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={"name": "No Token Gmail", "method": "GMAIL_API"},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/gmail/fetch")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gmail_fetch_wrong_method_returns_400(self, authed_client: TestClient):
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources", json={"name": "IMAP3", "method": "IMAP"}
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/gmail/fetch")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_gmail_fetch_with_mocked_client(self, authed_client: TestClient):
|
||||
"""Fetch with valid token (mocked GmailClient) returns success summary."""
|
||||
create_resp = authed_client.post(
|
||||
"/api/v1/mail-sources",
|
||||
json={
|
||||
"name": "Fetch Gmail",
|
||||
"method": "GMAIL_API",
|
||||
"gmail_client_id": "cid",
|
||||
"gmail_client_secret": "csec",
|
||||
},
|
||||
)
|
||||
source_id = create_resp.json()["id"]
|
||||
|
||||
mock_fetch_results = {
|
||||
"success": True,
|
||||
"processed": 3,
|
||||
"reports_found": 2,
|
||||
"new_domains": ["example.com"],
|
||||
"errors": [],
|
||||
"new_ingested_ids": ["id1", "id2", "id3"],
|
||||
}
|
||||
mock_client = MagicMock()
|
||||
mock_client.fetch_reports.return_value = mock_fetch_results
|
||||
mock_client.get_refreshed_tokens.return_value = None
|
||||
|
||||
with patch(
|
||||
"app.api.api_v1.endpoints.mail_sources._get_source_or_404"
|
||||
) as mock_get, patch(
|
||||
"app.api.api_v1.endpoints.mail_sources.GmailClient", return_value=mock_client
|
||||
):
|
||||
mock_source = MagicMock()
|
||||
mock_source.method = "GMAIL_API"
|
||||
mock_source.gmail_access_token = "tok"
|
||||
mock_source.gmail_refresh_token = "refresh"
|
||||
mock_source.gmail_client_id = "cid"
|
||||
mock_source.gmail_client_secret = "csec"
|
||||
mock_source.gmail_ingested_ids = "[]"
|
||||
mock_source.id = source_id
|
||||
mock_get.return_value = mock_source
|
||||
|
||||
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/gmail/fetch")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["processed"] == 3
|
||||
assert data["reports_found"] == 2
|
||||
assert data["new_domains"] == ["example.com"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GmailClient unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGmailClientHelpers:
|
||||
"""Unit tests for GmailClient static helpers."""
|
||||
|
||||
def test_load_ingested_ids_empty_string(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
assert GmailClient.load_ingested_ids("") == []
|
||||
|
||||
def test_load_ingested_ids_none(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
assert GmailClient.load_ingested_ids(None) == []
|
||||
|
||||
def test_load_ingested_ids_valid_json(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
result = GmailClient.load_ingested_ids('["id1", "id2"]')
|
||||
assert result == ["id1", "id2"]
|
||||
|
||||
def test_load_ingested_ids_invalid_json(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
assert GmailClient.load_ingested_ids("not-json") == []
|
||||
|
||||
def test_dump_ingested_ids(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
result = GmailClient.dump_ingested_ids(["id1", "id2"])
|
||||
assert '"id1"' in result
|
||||
assert '"id2"' in result
|
||||
|
||||
def test_build_authorization_url(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
url = GmailClient.build_authorization_url(
|
||||
client_id="test-client-id",
|
||||
redirect_uri="https://example.com/callback",
|
||||
state="42",
|
||||
)
|
||||
assert "accounts.google.com" in url
|
||||
assert "test-client-id" in url
|
||||
assert "gmail.readonly" in url
|
||||
assert "offline" in url
|
||||
assert "consent" in url
|
||||
|
||||
def test_build_authorization_url_no_state(self):
|
||||
from app.services.gmail_client import GmailClient
|
||||
|
||||
url = GmailClient.build_authorization_url(
|
||||
client_id="cid",
|
||||
redirect_uri="https://example.com/cb",
|
||||
)
|
||||
assert "state=" not in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_for_log helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,4 +24,7 @@ email-validator>=2.0.0
|
||||
lxml>=4.9.2
|
||||
zipfile36>=0.1.3
|
||||
aiosmtplib>=2.0.2
|
||||
jinja2>=3.1.2
|
||||
jinja2>=3.1.2
|
||||
google-auth>=2.0.0
|
||||
google-api-python-client>=2.0.0
|
||||
google-auth-httplib2>=0.2.0
|
||||
Reference in New Issue
Block a user