From 5c404155909e7a0287e02b5d3dab646f97dd4b23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:00:47 +0000 Subject: [PATCH] Add Gmail debug email functionality - GmailService: add get_or_create_label() and inject_debug_email() methods - providers.py: add POST /providers/gmail/debug-email endpoint - api.ts: add GmailDebugEmailResponse interface and gmailApi.sendDebugEmail() - settings/page.tsx: add Send Debug Email button with success/error feedback - 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/c609ede7-b9d7-4ab1-acdb-8d29164066cc --- CHANGELOG.md | 7 ++ backend/app/api/v1/endpoints/providers.py | 75 +++++++++++++- backend/app/services/gmail_service.py | 117 ++++++++++++++++++++++ docs/TODO.md | 1 + frontend/src/app/settings/page.tsx | 92 ++++++++++++----- frontend/src/lib/api.ts | 13 +++ 6 files changed, 277 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb199dc..9030958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Gmail Debug Email**: New "Send Debug Email" button in the Gmail API settings section. When clicked, it injects a test email into the user's Gmail inbox via the Gmail API. The message appears to be from `christian@docuelevate.org`, includes the current date in the subject line, and is automatically labelled with `test` and `imported` (labels are created on first use) and placed in the inbox. Useful for verifying end-to-end Gmail API delivery without requiring a full mail-account polling cycle. +- `GmailService.get_or_create_label()` async method: lists the user's Gmail labels and returns the matching label ID, creating the label if it does not yet exist. +- `GmailService.inject_debug_email()` async method: builds a properly formatted RFC 2822 test message and calls `inject_email()` with the INBOX, `test`, and `imported` label IDs. +- `POST /providers/gmail/debug-email` backend endpoint: requires a valid Gmail credential, injects the debug email, and persists any auto-refreshed access token. +- `gmailApi.sendDebugEmail()` frontend API helper and `GmailDebugEmailResponse` TypeScript interface. - **Unified Google OAuth flow**: Google Sign-In now requests all Gmail API scopes (`gmail.insert`, `gmail.labels`, `gmail.readonly`) in the same consent screen, so users no longer need a separate "Connect Gmail" step after signing in with Google. Gmail credentials are stored automatically on successful sign-in. - `include_granted_scopes=true` added to both the login and Gmail authorize URLs so scope additions take effect for users who previously connected. ### Changed +- `providers.py` now imports both `encrypt_credential` and `decrypt_credential` from `app.core.security`. +- `gmail_service.py` now imports `textwrap`, `MIMEText`, `format_datetime`, and `datetime`/`timezone` for the debug email builder. - `GMAIL_API_SCOPES` (providers endpoint) and `GMAIL_SCOPES` (GmailService) now include `gmail.readonly`, required for `users().getProfile()` access verification (fixes 403 insufficientPermissions errors). - Google Sign-In authorize URL (`GET /auth/google/authorize-url`) now requests all six scopes with `access_type=offline`, `prompt=consent`, and `include_granted_scopes=true` so a refresh token is always issued. - Gmail "Connect Gmail" button in Settings now redirects to `/auth/callback?state=gmail_connect` instead of the dedicated `/auth/gmail-callback` page, reducing the number of redirect URIs that must be registered in Google Cloud Console to one (`{origin}/auth/callback`). diff --git a/backend/app/api/v1/endpoints/providers.py b/backend/app/api/v1/endpoints/providers.py index 480ef4a..59db266 100644 --- a/backend/app/api/v1/endpoints/providers.py +++ b/backend/app/api/v1/endpoints/providers.py @@ -11,7 +11,7 @@ import logging from app.core.database import get_db from app.core.deps import get_current_active_user -from app.core.security import encrypt_credential +from app.core.security import encrypt_credential, decrypt_credential from app.core.config import settings from app.models.database_models import User, GmailCredential from app.models.schemas import ( @@ -22,7 +22,7 @@ from app.models.schemas import ( GmailAuthorizeResponse, GmailCallbackRequest, ) -from app.services.gmail_service import GmailService, GMAIL_SCOPES +from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES router = APIRouter() logger = logging.getLogger(__name__) @@ -319,6 +319,77 @@ async def get_gmail_authorize_url( return GmailAuthorizeResponse(authorization_url=url) +@router.post("/gmail/debug-email", status_code=status.HTTP_200_OK) +async def send_gmail_debug_email( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """ + Inject a debug/test email into the current user's Gmail inbox. + + The message appears to have been sent by christian@docuelevate.org, + carries today's date in the subject, and is tagged with the custom + labels "test" and "imported" as well as placed in the inbox. + + Useful for verifying that Gmail API delivery is working end-to-end + without requiring an active mail-account polling cycle. + """ + result = await db.execute( + select(GmailCredential).where( + GmailCredential.user_id == current_user.id, + GmailCredential.is_valid == True, # noqa: E712 + ) + ) + credential = result.scalar_one_or_none() + + if not credential: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No valid Gmail credentials found. Connect Gmail first.", + ) + + access_token = decrypt_credential(credential.encrypted_access_token) # type: ignore[arg-type] + refresh_token = ( + decrypt_credential(credential.encrypted_refresh_token) # type: ignore[arg-type] + if credential.encrypted_refresh_token + else None + ) + + gmail_service = GmailService( + access_token=access_token, + refresh_token=refresh_token, + client_id=settings.GOOGLE_CLIENT_ID, + client_secret=settings.GOOGLE_CLIENT_SECRET, + ) + + try: + inject_result = await gmail_service.inject_debug_email( + recipient_email=credential.gmail_email, # type: ignore[arg-type] + ) + except GmailInjectionError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gmail injection failed: {exc}", + ) + + # Persist refreshed token if the google-auth library renewed it + refreshed = gmail_service.get_refreshed_token() + if refreshed: + credential.encrypted_access_token = encrypt_credential( # type: ignore[assignment] + refreshed["access_token"] + ) + if refreshed.get("expiry"): + credential.token_expiry = refreshed["expiry"] # type: ignore[assignment] + await db.commit() + + return { + "message": "Debug email injected successfully", + "message_id": inject_result.get("message_id"), + "thread_id": inject_result.get("thread_id"), + "label_ids": inject_result.get("label_ids", []), + } + + @router.post( "/gmail/callback", response_model=GmailCredentialResponse, diff --git a/backend/app/services/gmail_service.py b/backend/app/services/gmail_service.py index e6ba816..18221e5 100644 --- a/backend/app/services/gmail_service.py +++ b/backend/app/services/gmail_service.py @@ -9,6 +9,10 @@ This is preferred over SMTP forwarding as it doesn't modify the email. import asyncio import base64 import logging +import textwrap +from datetime import datetime, timezone +from email.mime.text import MIMEText +from email.utils import format_datetime from typing import Optional, Dict, Any from google.oauth2.credentials import Credentials @@ -184,6 +188,119 @@ class GmailService: logger.error(f"Failed to get Gmail email address: {e}") return None + async def get_or_create_label(self, name: str) -> str: + """ + Return the Gmail label ID for a label with the given name. + + Lists the user's existing labels and returns the ID of the first + match (case-insensitive). If no matching label is found, a new + label is created and its ID is returned. + + Args: + name: Human-readable label name (e.g. "test", "imported"). + + Returns: + Gmail label ID string (e.g. "Label_1234567890"). + + Raises: + GmailInjectionError: If the Gmail API call fails. + """ + loop = asyncio.get_event_loop() + + try: + labels_resp = await loop.run_in_executor( + None, + lambda: self.service.users().labels().list(userId="me").execute(), + ) + for label in labels_resp.get("labels", []): + if label.get("name", "").lower() == name.lower(): + return label["id"] + + # Label not found – create it + created = await loop.run_in_executor( + None, + lambda: self.service.users() + .labels() + .create(userId="me", body={"name": name}) + .execute(), + ) + logger.info(f"Created Gmail label '{name}' with id={created['id']}") + return created["id"] + + except HttpError as e: + error_msg = f"Gmail API error while managing label '{name}': {e.reason if hasattr(e, 'reason') else str(e)}" + logger.error(error_msg) + raise GmailInjectionError(error_msg) + except Exception as e: + error_msg = f"Failed to get/create Gmail label '{name}': {str(e)}" + logger.error(error_msg) + raise GmailInjectionError(error_msg) + + async def inject_debug_email( + self, + recipient_email: str, + ) -> Dict[str, Any]: + """ + Inject a debug/test email into the user's Gmail inbox. + + The message is made to appear as if it was sent by + christian@docuelevate.org on the current date. It is placed in + the inbox and tagged with the custom labels "test" and "imported" + so it is easy to identify and clean up. + + Args: + recipient_email: The Gmail address to deliver the message to + (the authenticated user's address). + + Returns: + Dict with message_id, thread_id, and label_ids. + + Raises: + GmailInjectionError: If injection or label management fails. + """ + now = datetime.now(timezone.utc) + date_str = now.strftime("%d %B %Y") # e.g. "25 March 2026" + + subject = f"Test Import – {date_str}" + + body = textwrap.dedent(f"""\ + Hi there, + + This is an automated test message injected via the Gmail API to + confirm that the import pipeline is working correctly. + + Date: {date_str} + Source: DocuElevate Integration Test + + If you can see this message in your inbox it means that Gmail API + delivery is functioning as expected. Feel free to delete it. + + Best regards, + Christian Loris + DocuElevate + """) + + msg = MIMEText(body, "plain", "utf-8") + msg["From"] = "Christian Loris " + msg["To"] = recipient_email + msg["Subject"] = subject + msg["Date"] = format_datetime(now) + msg["Message-ID"] = f"" + + raw_bytes = msg.as_bytes() + + # Resolve label IDs (create labels if they don't exist yet) + test_label_id = await self.get_or_create_label("test") + imported_label_id = await self.get_or_create_label("imported") + + label_ids = ["INBOX", test_label_id, imported_label_id] + + return await self.inject_email( + raw_email=raw_bytes, + label_ids=label_ids, + source_account_name="debug", + ) + def get_refreshed_token(self) -> Optional[Dict[str, Any]]: """ Return the current access token and expiry if the token was refreshed diff --git a/docs/TODO.md b/docs/TODO.md index ee4e0f0..5451c04 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -184,6 +184,7 @@ Comprehensive task breakdown for repository improvements and production readines - [x] Gmail API one-click OAuth grant flow with token refresh and revocation handling - [x] Unified Google OAuth flow: sign-in requests all Gmail scopes; single `/auth/callback` redirect URI needed in Google Console - [x] Message deduplication (POP3 UIDL + IMAP \Seen flag + DB tracking) +- [x] **Debug email**: "Send Debug Email" button in Settings injects a test message (from christian@docuelevate.org, dated today, labelled `test` + `imported`, placed in inbox) to verify end-to-end Gmail API delivery - [ ] Implement GDPR data export endpoint - [ ] Complete notification service integration (Apprise) - [ ] Add advanced email filtering diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index ab23cfd..a12f6a0 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -15,6 +15,7 @@ import { Server, AlertTriangle, XCircle, + Bug, } from 'lucide-react'; export default function SettingsPage() { @@ -104,6 +105,19 @@ function SettingsContent() { }, }); + const [debugEmailResult, setDebugEmailResult] = useState(null); + const sendDebugEmailMutation = useMutation({ + mutationFn: gmailApi.sendDebugEmail, + onSuccess: () => { + setDebugEmailResult('success'); + setTimeout(() => setDebugEmailResult(null), 5000); + }, + onError: () => { + setDebugEmailResult('error'); + setTimeout(() => setDebugEmailResult(null), 5000); + }, + }); + const saveSmtpMutation = useMutation({ mutationFn: smtpApi.save, onSuccess: () => { @@ -302,35 +316,61 @@ function SettingsContent() { )} {!gmailLoading && gmailConnected && ( -
-
- -
-

- Connected as {gmailCredential.gmail_email} -

- {gmailCredential.last_verified_at && ( -

- Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()} +

+
+
+ +
+

+ Connected as {gmailCredential.gmail_email}

- )} + {gmailCredential.last_verified_at && ( +

+ Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()} +

+ )} +
+
+
+ + +
-
- - -
+ {debugEmailResult === 'success' && ( +
+ + Debug email injected successfully. Check your Gmail inbox — it should be labelled test and imported. +
+ )} + {debugEmailResult === 'error' && ( +
+ + Failed to inject debug email. Check that Gmail API access is still valid. +
+ )}
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4c64f2d..ea515cf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -126,6 +126,13 @@ export interface GmailCredential { updated_at: string; } +export interface GmailDebugEmailResponse { + message: string; + message_id: string | null; + thread_id: string | null; + label_ids: string[]; +} + export interface UserSmtpConfig { id: number; user_id: number; @@ -303,6 +310,12 @@ export const gmailApi = { async disconnect(): Promise { await api.delete('/providers/gmail-credential'); }, + + /** Inject a debug test email into the user's Gmail inbox. */ + async sendDebugEmail(): Promise { + const response = await api.post('/providers/gmail/debug-email'); + return response.data; + }, }; // ── SMTP Config API ─────────────────────────────────────────────────────