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
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <christian@docuelevate.org>"
|
||||
msg["To"] = recipient_email
|
||||
msg["Subject"] = subject
|
||||
msg["Date"] = format_datetime(now)
|
||||
msg["Message-ID"] = f"<debug-{now.strftime('%Y%m%d%H%M%S')}@docuelevate.org>"
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string | null>(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 && (
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
|
||||
</p>
|
||||
{gmailCredential.last_verified_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Connected as <span className="font-semibold">{gmailCredential.gmail_email}</span>
|
||||
</p>
|
||||
)}
|
||||
{gmailCredential.last_verified_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Last verified: {new Date(gmailCredential.last_verified_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => sendDebugEmailMutation.mutate()}
|
||||
disabled={sendDebugEmailMutation.isPending}
|
||||
title="Inject a test email into your Gmail inbox"
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-amber-50 text-amber-700 rounded-md hover:bg-amber-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{sendDebugEmailMutation.isPending ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Sending…</>
|
||||
) : (
|
||||
<><Bug className="h-4 w-4" />Send Debug Email</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConnectGmail}
|
||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Re-authorise
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnectGmail}
|
||||
disabled={disconnectGmailMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleConnectGmail}
|
||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Re-authorise
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDisconnectGmail}
|
||||
disabled={disconnectGmailMutation.isPending}
|
||||
className="px-4 py-2 text-sm bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
{debugEmailResult === 'success' && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 border border-green-200 rounded-md px-3 py-2">
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
Debug email injected successfully. Check your Gmail inbox — it should be labelled <strong className="mx-1">test</strong> and <strong className="mx-1">imported</strong>.
|
||||
</div>
|
||||
)}
|
||||
{debugEmailResult === 'error' && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
Failed to inject debug email. Check that Gmail API access is still valid.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await api.delete('/providers/gmail-credential');
|
||||
},
|
||||
|
||||
/** Inject a debug test email into the user's Gmail inbox. */
|
||||
async sendDebugEmail(): Promise<GmailDebugEmailResponse> {
|
||||
const response = await api.post<GmailDebugEmailResponse>('/providers/gmail/debug-email');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ── SMTP Config API ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user