From a1d258de01c213caabd424f476329e37ab6aaa9f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:10:55 +0000 Subject: [PATCH 1/3] Initial plan From a15216c630c87323050440527248db2300301991 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:20:22 +0000 Subject: [PATCH 2/3] feat: add account toggle, message dedup tracking, SMTP per-user and Gmail OAuth flow plan Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/b77800ca-b452-4427-b0bf-63403e906916 --- backend/app/api/v1/endpoints/mail_accounts.py | 34 ++++- backend/app/models/database_models.py | 38 ++++++ backend/app/services/mail_processor.py | 117 +++++++++++++----- backend/app/workers/tasks.py | 56 +++++++-- frontend/src/app/accounts/page.tsx | 43 ++++++- frontend/src/lib/api.ts | 5 + 6 files changed, 245 insertions(+), 48 deletions(-) diff --git a/backend/app/api/v1/endpoints/mail_accounts.py b/backend/app/api/v1/endpoints/mail_accounts.py index a61a94c..f5f28b2 100644 --- a/backend/app/api/v1/endpoints/mail_accounts.py +++ b/backend/app/api/v1/endpoints/mail_accounts.py @@ -8,7 +8,7 @@ from sqlalchemy import select, desc 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.models.database_models import User, MailAccount +from app.models.database_models import User, MailAccount, AccountStatus from app.models.schemas import ( MailAccountCreate, MailAccountResponse, @@ -182,6 +182,38 @@ async def delete_mail_account( await db.commit() +@router.patch("/{account_id}/toggle", response_model=MailAccountResponse) +async def toggle_mail_account( + account_id: int, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): + """Toggle the enabled/disabled state of a mail account""" + result = await db.execute( + select(MailAccount).where( + MailAccount.id == account_id, MailAccount.user_id == current_user.id + ) + ) + account = result.scalar_one_or_none() + + if not account: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Mail account not found" + ) + + account.is_enabled = not account.is_enabled # type: ignore[assignment] + + # When re-enabling a previously errored account, reset status to ACTIVE + # so the scheduler picks it up on the next run. + if account.is_enabled and account.status == AccountStatus.ERROR: + account.status = AccountStatus.ACTIVE # type: ignore[assignment] + + await db.commit() + await db.refresh(account) + + return account + + @router.post("/test", response_model=MailAccountTestResponse) async def test_mail_connection( test_request: MailAccountTestRequest, diff --git a/backend/app/models/database_models.py b/backend/app/models/database_models.py index 3bf2126..f460239 100644 --- a/backend/app/models/database_models.py +++ b/backend/app/models/database_models.py @@ -446,6 +446,44 @@ class AuditLog(Base): ) +class DownloadedMessageId(Base): + """ + Tracks unique message IDs that have already been downloaded and forwarded. + + - For POP3: stores the UIDL string returned by the server. + - For IMAP: stores the IMAP UID (numeric string) of the message. + + This prevents re-processing the same message when delete_after_forward=False. + """ + + __tablename__ = "downloaded_message_ids" + + id = Column(Integer, primary_key=True, index=True) + mail_account_id = Column( + Integer, ForeignKey("mail_accounts.id", ondelete="CASCADE"), nullable=False + ) + + # Unique message identifier (UIDL for POP3, UID for IMAP) + message_uid = Column(String(512), nullable=False) + + downloaded_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + # Indexes — unique constraint prevents duplicates + __table_args__ = ( + Index( + "idx_account_message_uid", + "mail_account_id", + "message_uid", + unique=True, + ), + Index("idx_downloaded_at", "downloaded_at"), + ) + + class GmailCredential(Base): """Stores OAuth2 credentials for Gmail API access (per-user)""" diff --git a/backend/app/services/mail_processor.py b/backend/app/services/mail_processor.py index 9829bf8..144e6a5 100644 --- a/backend/app/services/mail_processor.py +++ b/backend/app/services/mail_processor.py @@ -11,7 +11,7 @@ from email import parser from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from email.utils import formatdate, make_msgid -from typing import List, Dict, Any, Optional, Tuple +from typing import List, Dict, Any, Optional, Set, Tuple import logging from aioimaplib import aioimaplib @@ -143,26 +143,42 @@ class MailProcessor: except Exception as e: return False, f"IMAP connection failed: {str(e)}" - async def fetch_emails(self, max_count: Optional[int] = None) -> List[bytes]: + async def fetch_emails( + self, + max_count: Optional[int] = None, + already_seen_uids: Optional[Set[str]] = None, + ) -> Tuple[List[bytes], List[str]]: """ Fetch emails from the mail server. - Returns list of raw email data. + + Args: + max_count: Maximum number of messages to fetch. + already_seen_uids: Set of message UIDs that have already been + processed and should be skipped. + + Returns: + A tuple of (raw_email_bytes_list, new_uid_strings_list). + The caller should persist the new UIDs to prevent re-processing. """ effective_max: int = max_count if max_count is not None else self.account.max_emails_per_check # type: ignore[assignment] + seen: Set[str] = already_seen_uids or set() if self.account.protocol in [MailProtocol.POP3, MailProtocol.POP3_SSL]: - return await self._fetch_pop3_emails(effective_max) + return await self._fetch_pop3_emails(effective_max, seen) else: - return await self._fetch_imap_emails(effective_max) + return await self._fetch_imap_emails(effective_max, seen) - async def _fetch_pop3_emails(self, max_count: int) -> List[bytes]: - """Fetch emails via POP3""" - emails = [] + async def _fetch_pop3_emails( + self, max_count: int, already_seen_uids: Set[str] + ) -> Tuple[List[bytes], List[str]]: + """Fetch emails via POP3, skipping already-downloaded UIDs.""" + emails: List[bytes] = [] + new_uids: List[str] = [] try: loop = asyncio.get_event_loop() - def fetch_pop3(): + def fetch_pop3() -> Tuple[List[bytes], List[str]]: # Connect if self.account.protocol == MailProtocol.POP3_SSL: context = ssl.create_default_context() @@ -181,27 +197,49 @@ class MailProcessor: pop_conn.user(self.account.username) pop_conn.pass_(self.password) - # Get message count - num_messages = len(pop_conn.list()[1]) + # Retrieve UIDL map: {msg_number: uid_string} + uidl_response = pop_conn.uidl() + uid_map: Dict[int, str] = {} + for entry in uidl_response[1]: + parts = entry.decode().split(" ", 1) + if len(parts) == 2: + uid_map[int(parts[0])] = parts[1].strip() + + num_messages = len(uid_map) logger.info( f"Found {num_messages} messages for account {self.account.id}" ) - fetched_emails = [] - messages_to_delete = [] + fetched: List[bytes] = [] + fetched_uids: List[str] = [] + messages_to_delete: List[int] = [] + fetched_count = 0 + + for msg_num, uid in uid_map.items(): + if fetched_count >= max_count: + break + + # Skip messages we already processed + if uid in already_seen_uids: + logger.debug( + f"Skipping already-downloaded message {uid} " + f"for account {self.account.id}" + ) + continue - # Fetch emails (limited by max_count) - for i in range(1, min(num_messages + 1, max_count + 1)): try: - response, lines, octets = pop_conn.retr(i) + response, lines, octets = pop_conn.retr(msg_num) email_data = b"\r\n".join(lines) - fetched_emails.append(email_data) - messages_to_delete.append(i) + fetched.append(email_data) + fetched_uids.append(uid) + messages_to_delete.append(msg_num) + fetched_count += 1 logger.info( - f"Retrieved message {i} from account {self.account.id}" + f"Retrieved message {msg_num} (uid={uid}) " + f"from account {self.account.id}" ) except Exception as e: - logger.error(f"Error retrieving message {i}: {e}") + logger.error(f"Error retrieving message {msg_num}: {e}") # Delete messages if configured if self.account.delete_after_forward: @@ -212,19 +250,22 @@ class MailProcessor: logger.error(f"Error deleting message {msg_id}: {e}") pop_conn.quit() - return fetched_emails + return fetched, fetched_uids - emails = await loop.run_in_executor(None, fetch_pop3) + emails, new_uids = await loop.run_in_executor(None, fetch_pop3) except Exception as e: logger.error(f"Error fetching POP3 emails: {e}") raise MailFetchError(f"POP3 fetch error: {str(e)}") - return emails + return emails, new_uids - async def _fetch_imap_emails(self, max_count: int) -> List[bytes]: - """Fetch emails via IMAP""" - emails = [] + async def _fetch_imap_emails( + self, max_count: int, already_seen_uids: Set[str] + ) -> Tuple[List[bytes], List[str]]: + """Fetch emails via IMAP, marking each message \Seen to prevent re-fetch.""" + emails: List[bytes] = [] + new_uids: List[str] = [] try: # Create IMAP client @@ -241,8 +282,8 @@ class MailProcessor: await imap_client.login(self.account.username, self.password) await imap_client.select("INBOX") - # Search for all messages - response = await imap_client.search("UNSEEN") # Only fetch unread + # Search for unseen messages only + response = await imap_client.search("UNSEEN") message_ids = response.lines[0].split() # Limit to max_count @@ -254,6 +295,18 @@ class MailProcessor: # Fetch each message for msg_id in message_ids: + uid_str = msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id) + + # Skip messages already tracked in our DB + if uid_str in already_seen_uids: + logger.debug( + f"Skipping already-processed IMAP message {uid_str} " + f"for account {self.account.id}" + ) + # Still mark as Seen so it doesn't show up in UNSEEN searches + await imap_client.store(msg_id, "+FLAGS", "\\Seen") + continue + try: response = await imap_client.fetch(msg_id, "(RFC822)") @@ -272,8 +325,12 @@ class MailProcessor: if email_data: emails.append(email_data) + new_uids.append(uid_str) + + # Always mark as Seen after fetching so the message is + # not picked up again on the next UNSEEN search. + await imap_client.store(msg_id, "+FLAGS", "\\Seen") - # Mark as seen if deleting after forward if self.account.delete_after_forward: await imap_client.store(msg_id, "+FLAGS", "\\Deleted") @@ -290,7 +347,7 @@ class MailProcessor: logger.error(f"Error fetching IMAP emails: {e}") raise MailFetchError(f"IMAP fetch error: {str(e)}") - return emails + return emails, new_uids @staticmethod async def forward_email( diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 4038659..bd33f00 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -17,12 +17,13 @@ from app.models.database_models import ( AccountStatus, DeliveryMethod, GmailCredential, + DownloadedMessageId, ) from app.services.mail_processor import MailProcessor from app.services.gmail_service import GmailService from app.services.config_service import ConfigService from app.core.config import settings -from sqlalchemy import select, and_ +from sqlalchemy import select, and_, delete logger = logging.getLogger(__name__) @@ -69,11 +70,22 @@ async def process_mail_account(account_id: int): # Decrypt password password = decrypt_credential(account.encrypted_password) # type: ignore[arg-type] + # Load already-downloaded UIDs to prevent re-processing + seen_result = await db.execute( + select(DownloadedMessageId.message_uid).where( + DownloadedMessageId.mail_account_id == account.id + ) + ) + already_seen_uids = set(seen_result.scalars().all()) + # Create processor processor = MailProcessor(account, password) - # Fetch emails - emails = await processor.fetch_emails(account.max_emails_per_check) # type: ignore[arg-type] + # Fetch emails (returns raw bytes + new UIDs) + emails, new_uids = await processor.fetch_emails( + account.max_emails_per_check, # type: ignore[arg-type] + already_seen_uids=already_seen_uids, + ) run.emails_fetched = len(emails) # type: ignore[assignment] @@ -130,7 +142,9 @@ async def process_mail_account(account_id: int): await db.commit() return - for email_data in emails: + successfully_forwarded_uids: list[str] = [] + + for email_data, uid in zip(emails, new_uids): try: if use_gmail_api and gmail_service: # Inject via Gmail API (preferred) @@ -140,6 +154,7 @@ async def process_mail_account(account_id: int): source_account_name=account.name, # type: ignore[arg-type] ) emails_forwarded += 1 + successfully_forwarded_uids.append(uid) else: # Forward via SMTP (fallback) success = await MailProcessor.forward_email( @@ -147,6 +162,7 @@ async def process_mail_account(account_id: int): ) if success: emails_forwarded += 1 + successfully_forwarded_uids.append(uid) else: emails_failed += 1 @@ -154,6 +170,16 @@ async def process_mail_account(account_id: int): logger.error(f"Error delivering email: {e}") emails_failed += 1 + # Persist new message UIDs so they are not processed again + for uid in successfully_forwarded_uids: + if uid not in already_seen_uids: + db.add( + DownloadedMessageId( + mail_account_id=account.id, + message_uid=uid, + ) + ) + # Update run run.emails_forwarded = emails_forwarded # type: ignore[assignment] run.emails_failed = emails_failed # type: ignore[assignment] @@ -210,15 +236,11 @@ async def process_all_enabled_accounts(): """ async with async_session_maker() as db: try: - # Get all enabled accounts + # Fetch all enabled accounts regardless of operational status so + # that accounts in ERROR state are retried automatically. result = await db.execute( select(MailAccount).where( - and_( - MailAccount.is_enabled == True, # noqa: E712 - MailAccount.status.in_( - [AccountStatus.ACTIVE, AccountStatus.TESTING] - ), - ) + MailAccount.is_enabled == True, # noqa: E712 ) ) accounts = result.scalars().all() @@ -248,10 +270,10 @@ async def process_all_enabled_accounts(): @celery_app.task(base=AsyncTask, name="app.workers.tasks.cleanup_old_logs") async def cleanup_old_logs(days_to_keep: int = 30): """ - Clean up old processing logs and runs. + Clean up old processing logs, runs, and downloaded message ID records. Args: - days_to_keep: Number of days of logs to retain + days_to_keep: Number of days of data to retain """ async with async_session_maker() as db: try: @@ -275,6 +297,14 @@ async def cleanup_old_logs(days_to_keep: int = 30): for log in old_logs: await db.delete(log) + # Delete old downloaded message ID records so the table doesn't + # grow unboundedly for accounts that never delete messages. + await db.execute( + delete(DownloadedMessageId).where( + DownloadedMessageId.downloaded_at < cutoff_date + ) + ) + await db.commit() logger.info( diff --git a/frontend/src/app/accounts/page.tsx b/frontend/src/app/accounts/page.tsx index 0a69fe8..35e08e5 100644 --- a/frontend/src/app/accounts/page.tsx +++ b/frontend/src/app/accounts/page.tsx @@ -4,7 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard'; import { DashboardLayout } from '@/components/DashboardLayout'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { mailAccountsApi, MailAccount } from '@/lib/api'; -import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { Plus, Edit2, Trash2, CheckCircle, XCircle, AlertTriangle, Power } from 'lucide-react'; import { useState } from 'react'; import { AddMailAccountModal } from '@/components/AddMailAccountModal'; @@ -25,6 +25,13 @@ export default function AccountsPage() { }, }); + const toggleMutation = useMutation({ + mutationFn: mailAccountsApi.toggle, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mail-accounts'] }); + }, + }); + const handleEdit = (account: MailAccount) => { setEditingAccount(account); setIsModalOpen(true); @@ -40,6 +47,14 @@ export default function AccountsPage() { } }; + const handleToggle = async (id: number) => { + try { + await toggleMutation.mutateAsync(id); + } catch { + alert('Failed to update account'); + } + }; + const handleCloseModal = () => { setIsModalOpen(false); setEditingAccount(null); @@ -69,7 +84,9 @@ export default function AccountsPage() { {accounts.map((account) => (
@@ -81,9 +98,15 @@ export default function AccountsPage() {
{account.is_enabled ? ( - + + + Enabled + ) : ( - + + + Disabled + )}
@@ -129,6 +152,18 @@ export default function AccountsPage() { )}
+ + + )} +
+
+ ); +} + +export default function GmailCallbackPage() { + return ( + + + + } + > + + + ); +} diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index f273ef0..13fc949 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -4,9 +4,18 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AuthGuard } from '@/components/AuthGuard'; import { DashboardLayout } from '@/components/DashboardLayout'; -import { userApi } from '@/lib/api'; +import { userApi, gmailApi, smtpApi } from '@/lib/api'; import { useAuthStore } from '@/store/authStore'; -import { CheckCircle, Loader2, User, Mail, Shield } from 'lucide-react'; +import { + CheckCircle, + Loader2, + User, + Mail, + Shield, + Server, + AlertTriangle, + XCircle, +} from 'lucide-react'; export default function SettingsPage() { return ( @@ -28,6 +37,16 @@ function SettingsContent() { }); const [profileSaved, setProfileSaved] = useState(false); + // SMTP form state + const [smtpForm, setSmtpForm] = useState({ + host: 'smtp.gmail.com', + port: 587, + username: '', + password: '', + use_tls: true, + }); + const [smtpSaved, setSmtpSaved] = useState(false); + // Refresh user data from the server const { data: currentUser } = useQuery({ queryKey: ['current-user'], @@ -35,6 +54,38 @@ function SettingsContent() { initialData: user ?? undefined, }); + // Gmail credential status + const { + data: gmailCredential, + isLoading: gmailLoading, + error: gmailError, + } = useQuery({ + queryKey: ['gmail-credential'], + queryFn: gmailApi.getCredential, + retry: false, + }); + + // SMTP config + const { data: smtpConfig, isLoading: smtpLoading } = useQuery({ + queryKey: ['smtp-config'], + queryFn: smtpApi.get, + retry: false, + }); + + // Pre-populate SMTP form when data loads + const [smtpFormPopulated, setSmtpFormPopulated] = useState(false); + if (smtpConfig && !smtpFormPopulated) { + setSmtpFormPopulated(true); + setSmtpForm((prev) => ({ + ...prev, + host: smtpConfig.host, + port: smtpConfig.port, + username: smtpConfig.username, + use_tls: smtpConfig.use_tls, + password: '', // never pre-fill password + })); + } + const updateProfileMutation = useMutation({ mutationFn: (data: { full_name: string; email: string }) => userApi.updateProfile(data), @@ -46,11 +97,45 @@ function SettingsContent() { }, }); + const disconnectGmailMutation = useMutation({ + mutationFn: gmailApi.disconnect, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gmail-credential'] }); + }, + }); + + const saveSmtpMutation = useMutation({ + mutationFn: smtpApi.save, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smtp-config'] }); + setSmtpSaved(true); + setTimeout(() => setSmtpSaved(false), 3000); + }, + }); + + const deleteSmtpMutation = useMutation({ + mutationFn: smtpApi.remove, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['smtp-config'] }); + setSmtpForm({ host: 'smtp.gmail.com', port: 587, username: '', password: '', use_tls: true }); + }, + }); + const handleProfileChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setProfileForm((prev) => ({ ...prev, [name]: value })); }; + const handleSmtpChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target; + setSmtpForm((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked + : name === 'port' ? Number(value) + : value, + })); + }; + const handleProfileSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -68,7 +153,55 @@ function SettingsContent() { } }; + const handleSmtpSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await saveSmtpMutation.mutateAsync({ + host: smtpForm.host, + port: smtpForm.port, + username: smtpForm.username, + password: smtpForm.password || undefined, + use_tls: smtpForm.use_tls, + }); + } catch (error) { + const errorMessage = + error instanceof Error && 'response' in error + ? (error as { response?: { data?: { detail?: string } } }).response?.data + ?.detail + : null; + alert(errorMessage || 'Failed to save SMTP settings'); + } + }; + + const handleConnectGmail = async () => { + try { + const redirectUri = `${window.location.origin}/auth/gmail-callback`; + const url = await gmailApi.getAuthorizeUrl(redirectUri); + window.location.href = url; + } catch (error) { + const errorMessage = + error instanceof Error && 'response' in error + ? (error as { response?: { data?: { detail?: string } } }).response?.data + ?.detail + : null; + alert(errorMessage || 'Failed to start Gmail authorization'); + } + }; + + const handleDisconnectGmail = async () => { + if (confirm('Disconnect Gmail? Mail accounts using Gmail API delivery will fall back to SMTP.')) { + try { + await disconnectGmailMutation.mutateAsync(); + } catch { + alert('Failed to disconnect Gmail'); + } + } + }; + const displayUser = currentUser ?? user; + const gmailConnected = gmailCredential?.is_valid === true; + // gmail 404 just means "not connected yet" — not a real error + const gmailNotConnected = !gmailCredential && !gmailLoading; return (
@@ -134,6 +267,218 @@ function SettingsContent() {
+ {/* Gmail API Section */} +
+
+ +

Gmail API Delivery

+
+
+

+ Grant this app permission to inject emails directly into your Gmail inbox. + This is the preferred delivery method — emails arrive with original headers + intact, bypassing SMTP entirely. +

+

+ Token lifetime: Access tokens expire after 1 hour and are + refreshed automatically. Refresh tokens do not expire unless you revoke + access via your{' '} + + Google Account permissions + + . If revoked, click “Connect Gmail” again to re-authorise. +

+ + {gmailLoading && ( +
+ + Checking Gmail connection… +
+ )} + + {!gmailLoading && gmailConnected && ( +
+
+ +
+

+ Connected as {gmailCredential.gmail_email} +

+ {gmailCredential.last_verified_at && ( +

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

+ )} +
+
+
+ + +
+
+ )} + + {!gmailLoading && gmailCredential && !gmailCredential.is_valid && ( +
+ +
+ Gmail access was revoked. Click “Re-authorise” below to restore Gmail API delivery. +
+
+ )} + + {!gmailLoading && (gmailNotConnected || (gmailCredential && !gmailCredential.is_valid)) && ( + + )} + + {!gmailLoading && gmailNotConnected && ( +
+ + No Gmail account connected yet. +
+ )} +
+
+ + {/* SMTP Fallback Section */} +
+
+ +

SMTP Fallback

+
+
+

+ Used when Gmail API is not connected or when a mail account is configured + to use SMTP delivery. Your credentials are stored encrypted. +

+ + {smtpLoading ? ( +
+ + Loading SMTP settings… +
+ ) : ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {smtpConfig && ( + + )} + {smtpSaved && ( + + + Saved + + )} +
+
+ )} +
+
+ {/* Account Information */}
@@ -204,3 +549,4 @@ function SettingsContent() {
); } + diff --git a/frontend/src/components/AddMailAccountModal.tsx b/frontend/src/components/AddMailAccountModal.tsx index 75c2c72..8b3c834 100644 --- a/frontend/src/components/AddMailAccountModal.tsx +++ b/frontend/src/components/AddMailAccountModal.tsx @@ -315,6 +315,19 @@ export function AddMailAccountModal({ account, onClose }: AddMailAccountModalPro Delete after forwarding
+
+ + +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 15855c5..4c64f2d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -116,6 +116,28 @@ export interface AutoDetectSuggestion { [key: string]: unknown; } +export interface GmailCredential { + id: number; + user_id: number; + gmail_email: string; + is_valid: boolean; + last_verified_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface UserSmtpConfig { + id: number; + user_id: number; + host: string; + port: number; + username: string; + use_tls: boolean; + has_password: boolean; + created_at: string; + updated_at: string; +} + interface TokenResponse { access_token: string; refresh_token: string; @@ -250,4 +272,61 @@ export const processingRunsApi = { }, }; +// ── Gmail API ─────────────────────────────────────────────────────────── + +export const gmailApi = { + /** Returns the Google OAuth2 URL the user should be redirected to. */ + async getAuthorizeUrl(redirectUri: string): Promise { + const response = await api.get<{ authorization_url: string }>( + '/providers/gmail/authorize-url', + { params: { redirect_uri: redirectUri } } + ); + return response.data.authorization_url; + }, + + /** Exchange an OAuth2 code for Gmail tokens and persist them. */ + async saveCallback(code: string, redirectUri: string): Promise { + const response = await api.post('/providers/gmail/callback', { + code, + redirect_uri: redirectUri, + }); + return response.data; + }, + + /** Get the current user's stored Gmail credential status. */ + async getCredential(): Promise { + const response = await api.get('/providers/gmail-credential'); + return response.data; + }, + + /** Remove stored Gmail credentials. */ + async disconnect(): Promise { + await api.delete('/providers/gmail-credential'); + }, +}; + +// ── SMTP Config API ───────────────────────────────────────────────────── + +export const smtpApi = { + async get(): Promise { + const response = await api.get('/users/smtp-config'); + return response.data; + }, + + async save(data: { + host: string; + port: number; + username: string; + password?: string; + use_tls: boolean; + }): Promise { + const response = await api.put('/users/smtp-config', data); + return response.data; + }, + + async remove(): Promise { + await api.delete('/users/smtp-config'); + }, +}; + export default api;