Add Gmail API injection, provider presets, delivery method, and frontend wizard
- Add gmail_service.py with Gmail API users.messages.insert() for direct email injection - Add DeliveryMethod enum and GmailCredential model to database models - Add delivery_method field to MailAccount schema and model - Create providers.py endpoint with 12 provider presets (Gmail, GMX, WEB.DE, Outlook, Yahoo, AOL, T-Online, 1&1/IONOS, Freenet, Posteo, mail.de, iCloud) - Expand MailServerAutoDetect with 30+ domain mappings - Update Celery tasks to prefer Gmail API injection, with SMTP fallback - Add ProviderWizard.tsx frontend component for quick provider setup - Update AddMailAccountModal.tsx with wizard integration - Add Google API Python client libraries to requirements.txt - Add Gmail API config settings - Add unit tests for Gmail service and provider presets Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/de3ef930-a980-4958-8a9d-a2c802918e81
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Gmail API service for injecting emails directly into Gmail.
|
||||
|
||||
Uses the Gmail API's users.messages.insert() method to inject emails
|
||||
into a user's Gmail account, preserving original headers and metadata.
|
||||
This is preferred over SMTP forwarding as it doesn't modify the email.
|
||||
"""
|
||||
import base64
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from google.oauth2.credentials import Credentials
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gmail API scopes needed for email injection
|
||||
GMAIL_SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.insert",
|
||||
"https://www.googleapis.com/auth/gmail.labels",
|
||||
]
|
||||
|
||||
|
||||
class GmailInjectionError(Exception):
|
||||
"""Raised when Gmail API injection fails"""
|
||||
pass
|
||||
|
||||
|
||||
class GmailService:
|
||||
"""
|
||||
Service for injecting emails into Gmail via the Gmail API.
|
||||
|
||||
Uses users.messages.insert() which places emails directly into
|
||||
the user's mailbox without sending them through SMTP.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None,
|
||||
token_uri: str = "https://oauth2.googleapis.com/token",
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize Gmail service with OAuth2 credentials.
|
||||
|
||||
Args:
|
||||
access_token: Valid OAuth2 access token
|
||||
refresh_token: OAuth2 refresh token for automatic renewal
|
||||
token_uri: OAuth2 token endpoint
|
||||
client_id: Google OAuth2 client ID
|
||||
client_secret: Google OAuth2 client secret
|
||||
"""
|
||||
self.credentials = Credentials(
|
||||
token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=token_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
scopes=GMAIL_SCOPES,
|
||||
)
|
||||
self._service = None
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
"""Lazy-initialize the Gmail API service."""
|
||||
if self._service is None:
|
||||
self._service = build("gmail", "v1", credentials=self.credentials)
|
||||
return self._service
|
||||
|
||||
async def inject_email(
|
||||
self,
|
||||
raw_email: bytes,
|
||||
label_ids: Optional[list] = None,
|
||||
source_account_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Inject a raw email into the user's Gmail account.
|
||||
|
||||
Uses users.messages.insert() to place the email directly
|
||||
into the mailbox. The email appears as if it was received
|
||||
normally, preserving all original headers.
|
||||
|
||||
Args:
|
||||
raw_email: Raw email bytes (RFC 2822 format)
|
||||
label_ids: Gmail label IDs to apply (defaults to ["INBOX"])
|
||||
source_account_name: Optional name for logging
|
||||
|
||||
Returns:
|
||||
Dict with message id and thread id
|
||||
|
||||
Raises:
|
||||
GmailInjectionError: If injection fails
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
if label_ids is None:
|
||||
label_ids = ["INBOX"]
|
||||
|
||||
# Base64url encode the raw email
|
||||
encoded_message = base64.urlsafe_b64encode(raw_email).decode("utf-8")
|
||||
|
||||
message_body = {
|
||||
"raw": encoded_message,
|
||||
"labelIds": label_ids,
|
||||
}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.messages()
|
||||
.insert(userId="me", body=message_body)
|
||||
.execute(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Injected email into Gmail: id={result.get('id')}"
|
||||
f"{f' from {source_account_name}' if source_account_name else ''}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message_id": result.get("id"),
|
||||
"thread_id": result.get("threadId"),
|
||||
"label_ids": result.get("labelIds", []),
|
||||
}
|
||||
|
||||
except HttpError as e:
|
||||
error_msg = f"Gmail API error: {e.reason if hasattr(e, 'reason') else str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to inject email into Gmail: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise GmailInjectionError(error_msg)
|
||||
|
||||
async def verify_access(self) -> bool:
|
||||
"""
|
||||
Verify that the Gmail API credentials are valid.
|
||||
|
||||
Returns:
|
||||
True if credentials are valid and can access Gmail
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.getProfile(userId="me")
|
||||
.execute(),
|
||||
)
|
||||
email = result.get("emailAddress", "unknown")
|
||||
logger.info(f"Gmail API access verified for: {email}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Gmail API access verification failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_email_address(self) -> Optional[str]:
|
||||
"""
|
||||
Get the email address associated with the Gmail credentials.
|
||||
|
||||
Returns:
|
||||
Email address string or None if unavailable
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: self.service.users()
|
||||
.getProfile(userId="me")
|
||||
.execute(),
|
||||
)
|
||||
return result.get("emailAddress")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Gmail email address: {e}")
|
||||
return None
|
||||
@@ -395,6 +395,11 @@ class MailServerAutoDetect:
|
||||
"pop3_ssl": {"host": "pop.gmail.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmail.com", "port": 993},
|
||||
},
|
||||
"googlemail.com": {
|
||||
"name": "Gmail",
|
||||
"pop3_ssl": {"host": "pop.gmail.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmail.com", "port": 993},
|
||||
},
|
||||
"outlook.com": {
|
||||
"name": "Outlook.com",
|
||||
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
|
||||
@@ -405,6 +410,21 @@ class MailServerAutoDetect:
|
||||
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
|
||||
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
|
||||
},
|
||||
"live.com": {
|
||||
"name": "Live",
|
||||
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
|
||||
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
|
||||
},
|
||||
"msn.com": {
|
||||
"name": "MSN",
|
||||
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
|
||||
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
|
||||
},
|
||||
"outlook.de": {
|
||||
"name": "Outlook.de",
|
||||
"pop3_ssl": {"host": "outlook.office365.com", "port": 995},
|
||||
"imap_ssl": {"host": "outlook.office365.com", "port": 993},
|
||||
},
|
||||
"gmx.com": {
|
||||
"name": "GMX",
|
||||
"pop3_ssl": {"host": "pop.gmx.com", "port": 995},
|
||||
@@ -415,6 +435,21 @@ class MailServerAutoDetect:
|
||||
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
|
||||
},
|
||||
"gmx.net": {
|
||||
"name": "GMX",
|
||||
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
|
||||
},
|
||||
"gmx.at": {
|
||||
"name": "GMX",
|
||||
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
|
||||
},
|
||||
"gmx.ch": {
|
||||
"name": "GMX",
|
||||
"pop3_ssl": {"host": "pop.gmx.net", "port": 995},
|
||||
"imap_ssl": {"host": "imap.gmx.net", "port": 993},
|
||||
},
|
||||
"web.de": {
|
||||
"name": "WEB.DE",
|
||||
"pop3_ssl": {"host": "pop3.web.de", "port": 995},
|
||||
@@ -422,14 +457,84 @@ class MailServerAutoDetect:
|
||||
},
|
||||
"t-online.de": {
|
||||
"name": "T-Online",
|
||||
"pop3_ssl": {"host": "pop.t-online.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.t-online.de", "port": 993},
|
||||
"pop3_ssl": {"host": "securepop.t-online.de", "port": 995},
|
||||
"imap_ssl": {"host": "secureimap.t-online.de", "port": 993},
|
||||
},
|
||||
"yahoo.com": {
|
||||
"name": "Yahoo",
|
||||
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
|
||||
},
|
||||
"yahoo.de": {
|
||||
"name": "Yahoo",
|
||||
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
|
||||
},
|
||||
"yahoo.co.uk": {
|
||||
"name": "Yahoo",
|
||||
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
|
||||
},
|
||||
"ymail.com": {
|
||||
"name": "Yahoo",
|
||||
"pop3_ssl": {"host": "pop.mail.yahoo.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.yahoo.com", "port": 993},
|
||||
},
|
||||
"aol.com": {
|
||||
"name": "AOL",
|
||||
"pop3_ssl": {"host": "pop.aol.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.aol.com", "port": 993},
|
||||
},
|
||||
"aim.com": {
|
||||
"name": "AOL",
|
||||
"pop3_ssl": {"host": "pop.aol.com", "port": 995},
|
||||
"imap_ssl": {"host": "imap.aol.com", "port": 993},
|
||||
},
|
||||
"online.de": {
|
||||
"name": "1&1 / IONOS",
|
||||
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
|
||||
},
|
||||
"onlinehome.de": {
|
||||
"name": "1&1 / IONOS",
|
||||
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
|
||||
},
|
||||
"1und1.de": {
|
||||
"name": "1&1 / IONOS",
|
||||
"pop3_ssl": {"host": "pop.ionos.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.ionos.de", "port": 993},
|
||||
},
|
||||
"freenet.de": {
|
||||
"name": "Freenet",
|
||||
"pop3_ssl": {"host": "mx.freenet.de", "port": 995},
|
||||
"imap_ssl": {"host": "mx.freenet.de", "port": 993},
|
||||
},
|
||||
"posteo.de": {
|
||||
"name": "Posteo",
|
||||
"imap_ssl": {"host": "posteo.de", "port": 993},
|
||||
},
|
||||
"posteo.net": {
|
||||
"name": "Posteo",
|
||||
"imap_ssl": {"host": "posteo.de", "port": 993},
|
||||
},
|
||||
"icloud.com": {
|
||||
"name": "iCloud",
|
||||
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
|
||||
},
|
||||
"me.com": {
|
||||
"name": "iCloud",
|
||||
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
|
||||
},
|
||||
"mac.com": {
|
||||
"name": "iCloud",
|
||||
"imap_ssl": {"host": "imap.mail.me.com", "port": 993},
|
||||
},
|
||||
"mail.de": {
|
||||
"name": "mail.de",
|
||||
"pop3_ssl": {"host": "pop.mail.de", "port": 995},
|
||||
"imap_ssl": {"host": "imap.mail.de", "port": 993},
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
Reference in New Issue
Block a user