Merge pull request #81 from christianlouis/copilot/add-debug-functionality-gmail-integration
security: upgrade python-jose 3.3.0 → 3.4.0 (algorithm confusion with OpenSSH ECDSA keys)
This commit is contained in:
@@ -7,11 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Upgraded `python-jose[cryptography]` from `3.3.0` to `3.4.0` to fix an algorithm-confusion vulnerability with OpenSSH ECDSA keys (CVE affects all versions < 3.4.0).
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **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.
|
- `include_granted_scopes=true` added to both the login and Gmail authorize URLs so scope additions take effect for users who previously connected.
|
||||||
|
|
||||||
### Changed
|
### 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).
|
- `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.
|
- 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`).
|
- 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.database import get_db
|
||||||
from app.core.deps import get_current_active_user
|
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.core.config import settings
|
||||||
from app.models.database_models import User, GmailCredential
|
from app.models.database_models import User, GmailCredential
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
@@ -22,7 +22,7 @@ from app.models.schemas import (
|
|||||||
GmailAuthorizeResponse,
|
GmailAuthorizeResponse,
|
||||||
GmailCallbackRequest,
|
GmailCallbackRequest,
|
||||||
)
|
)
|
||||||
from app.services.gmail_service import GmailService, GMAIL_SCOPES
|
from app.services.gmail_service import GmailService, GmailInjectionError, GMAIL_SCOPES
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -319,6 +319,77 @@ async def get_gmail_authorize_url(
|
|||||||
return GmailAuthorizeResponse(authorization_url=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(
|
@router.post(
|
||||||
"/gmail/callback",
|
"/gmail/callback",
|
||||||
response_model=GmailCredentialResponse,
|
response_model=GmailCredentialResponse,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ This is preferred over SMTP forwarding as it doesn't modify the email.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import logging
|
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 typing import Optional, Dict, Any
|
||||||
|
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
@@ -184,6 +188,119 @@ class GmailService:
|
|||||||
logger.error(f"Failed to get Gmail email address: {e}")
|
logger.error(f"Failed to get Gmail email address: {e}")
|
||||||
return None
|
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]]:
|
def get_refreshed_token(self) -> Optional[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Return the current access token and expiry if the token was refreshed
|
Return the current access token and expiry if the token was refreshed
|
||||||
|
|||||||
@@ -185,6 +185,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] 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] 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] 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
|
- [ ] Implement GDPR data export endpoint
|
||||||
- [ ] Complete notification service integration (Apprise)
|
- [ ] Complete notification service integration (Apprise)
|
||||||
- [ ] Add advanced email filtering
|
- [ ] Add advanced email filtering
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
Bug,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
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({
|
const saveSmtpMutation = useMutation({
|
||||||
mutationFn: smtpApi.save,
|
mutationFn: smtpApi.save,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -302,6 +316,7 @@ function SettingsContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!gmailLoading && gmailConnected && (
|
{!gmailLoading && gmailConnected && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
@@ -316,7 +331,19 @@ function SettingsContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<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
|
<button
|
||||||
onClick={handleConnectGmail}
|
onClick={handleConnectGmail}
|
||||||
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors"
|
||||||
@@ -332,6 +359,19 @@ function SettingsContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!gmailLoading && gmailCredential && !gmailCredential.is_valid && (
|
{!gmailLoading && gmailCredential && !gmailCredential.is_valid && (
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ export interface GmailCredential {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GmailDebugEmailResponse {
|
||||||
|
message: string;
|
||||||
|
message_id: string | null;
|
||||||
|
thread_id: string | null;
|
||||||
|
label_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSmtpConfig {
|
export interface UserSmtpConfig {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -303,6 +310,12 @@ export const gmailApi = {
|
|||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
await api.delete('/providers/gmail-credential');
|
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 ─────────────────────────────────────────────────────
|
// ── SMTP Config API ─────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user