diff --git a/frontend/PLAN_EMAIL_WIZARD.md b/frontend/PLAN_EMAIL_WIZARD.md new file mode 100644 index 0000000..dfa32ad --- /dev/null +++ b/frontend/PLAN_EMAIL_WIZARD.md @@ -0,0 +1,133 @@ +# Email Configuration Wizard Plan + +## Overview + +Create a step-by-step wizard for configuring email settings with: +1. Auto-detection of IMAP/SMTP settings from email address +2. OAuth support for Gmail accounts +3. Manual configuration fallback + +## Wizard Steps + +### Step 1: Email Address Entry +- User enters their support email address (e.g., support@company.com) +- System extracts domain and attempts auto-detection +- Shows detected provider (Gmail, Outlook, Yahoo, custom domain) + +### Step 2: Authentication Method Selection +- **For Gmail**: Show "Connect with Google" OAuth button +- **For Outlook/Microsoft 365**: Show "Connect with Microsoft" OAuth button +- **For others**: Show manual configuration option + +### Step 3a: OAuth Flow (Gmail/Microsoft) +- Redirect to OAuth provider +- Request mail scopes (IMAP, SMTP access) +- Store OAuth tokens for authentication +- Auto-configure IMAP/SMTP settings + +### Step 3b: Manual Configuration +- Pre-fill detected IMAP/SMTP settings +- Allow user to modify if needed +- Password/app-specific password entry + +### Step 4: Test & Verify +- Test IMAP connection +- Test SMTP connection +- Show success or troubleshooting steps + +### Step 5: Additional Settings +- From name configuration +- Check interval +- Delete after processing toggle + +## Email Provider Database + +Common providers with auto-detection: + +| Domain | Provider | IMAP Host | IMAP Port | SMTP Host | SMTP Port | OAuth | +|--------|----------|-----------|-----------|-----------|-----------|-------| +| gmail.com | Gmail | imap.gmail.com | 993 | smtp.gmail.com | 587 | Yes | +| googlemail.com | Gmail | imap.gmail.com | 993 | smtp.gmail.com | 587 | Yes | +| outlook.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes | +| hotmail.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes | +| live.com | Microsoft | outlook.office365.com | 993 | smtp.office365.com | 587 | Yes | +| yahoo.com | Yahoo | imap.mail.yahoo.com | 993 | smtp.mail.yahoo.com | 587 | No | +| icloud.com | Apple | imap.mail.me.com | 993 | smtp.mail.me.com | 587 | No | +| aol.com | AOL | imap.aol.com | 993 | smtp.aol.com | 587 | No | + +For custom domains: Use MX record lookup to detect if hosted by Gmail/Microsoft + +## Backend Changes + +### New API Endpoints + +1. `POST /api/tickets/email-settings/detect/` + - Input: `{ email: "support@company.com" }` + - Output: Detected provider info and suggested settings + +2. `POST /api/tickets/email-settings/oauth/google/` + - Initiate Google OAuth flow for Gmail access + +3. `POST /api/tickets/email-settings/oauth/google/callback/` + - Handle OAuth callback, store tokens + +4. `POST /api/tickets/email-settings/oauth/microsoft/` + - Initiate Microsoft OAuth flow + +5. `POST /api/tickets/email-settings/oauth/microsoft/callback/` + - Handle Microsoft OAuth callback + +### Model Changes + +Add to TicketEmailSettings: +- `oauth_provider`: CharField (google, microsoft, null) +- `oauth_access_token`: TextField (encrypted) +- `oauth_refresh_token`: TextField (encrypted) +- `oauth_token_expiry`: DateTimeField +- `use_oauth`: BooleanField + +### OAuth Scopes Required + +**Google Gmail API:** +- `https://mail.google.com/` (full mail access for IMAP/SMTP) +- OR use Gmail API directly instead of IMAP + +**Microsoft Graph API:** +- `https://outlook.office.com/IMAP.AccessAsUser.All` +- `https://outlook.office.com/SMTP.Send` + +## Frontend Components + +### EmailConfigWizard.tsx +Main wizard component with step navigation + +### Steps: +1. EmailAddressStep - Email input with domain detection +2. AuthMethodStep - OAuth vs manual selection +3. OAuthConnectStep - OAuth flow handling +4. ManualConfigStep - IMAP/SMTP form fields +5. TestConnectionStep - Connection testing +6. FinalSettingsStep - Additional options + +## Implementation Order + +1. Backend: Email provider detection endpoint +2. Frontend: Wizard UI with steps +3. Backend: Google OAuth integration +4. Frontend: OAuth flow handling +5. Backend: Microsoft OAuth integration +6. Testing and refinement + +## Questions to Resolve + +1. Should we use IMAP/SMTP with OAuth tokens, or switch to Gmail/Graph API? + - IMAP/SMTP with XOAUTH2 is simpler, works with existing code + - API approach is more modern but requires rewriting email fetcher + +2. Store OAuth tokens in TicketEmailSettings or separate model? + - Same model is simpler + - Separate model allows multiple OAuth connections + +3. How to handle token refresh? + - Background task to refresh before expiry + - Refresh on-demand when making email requests diff --git a/frontend/src/api/ticketEmailSettings.ts b/frontend/src/api/ticketEmailSettings.ts index abfef92..3b0c8e6 100644 --- a/frontend/src/api/ticketEmailSettings.ts +++ b/frontend/src/api/ticketEmailSettings.ts @@ -78,6 +78,25 @@ export interface FetchNowResult { processed: number; } +export interface EmailProviderDetectResult { + success: boolean; + email: string; + domain: string; + detected: boolean; + detected_via?: 'domain_lookup' | 'mx_record'; + provider: 'google' | 'microsoft' | 'yahoo' | 'apple' | 'aol' | 'zoho' | 'protonmail' | 'unknown'; + display_name: string; + imap_host?: string; + imap_port?: number; + smtp_host?: string; + smtp_port?: number; + oauth_supported: boolean; + message?: string; + notes?: string; + suggested_imap_port?: number; + suggested_smtp_port?: number; +} + export interface IncomingTicketEmail { id: number; message_id: string; @@ -167,3 +186,77 @@ export const reprocessIncomingEmail = async (id: number): Promise<{ const response = await apiClient.post(`/api/tickets/incoming-emails/${id}/reprocess/`); return response.data; }; + +/** + * Detect email provider from email address + * Auto-detects Gmail, Outlook, Yahoo, iCloud, etc. from domain + * Also checks MX records for custom domains using Google Workspace or Microsoft 365 + */ +export const detectEmailProvider = async (email: string): Promise => { + const response = await apiClient.post('/api/tickets/email-settings/detect/', { email }); + return response.data; +}; + +// OAuth types and functions +export interface OAuthStatusResult { + google: { configured: boolean }; + microsoft: { configured: boolean }; +} + +export interface OAuthInitiateResult { + success: boolean; + authorization_url?: string; + error?: string; +} + +export interface OAuthCredential { + id: number; + provider: 'google' | 'microsoft'; + email: string; + purpose: string; + is_valid: boolean; + is_expired: boolean; + last_used_at: string | null; + last_error: string; + created_at: string; +} + +/** + * Get OAuth configuration status + */ +export const getOAuthStatus = async (): Promise => { + const response = await apiClient.get('/api/oauth/status/'); + return response.data; +}; + +/** + * Initiate Google OAuth flow + */ +export const initiateGoogleOAuth = async (purpose: string = 'email'): Promise => { + const response = await apiClient.post('/api/oauth/google/initiate/', { purpose }); + return response.data; +}; + +/** + * Initiate Microsoft OAuth flow + */ +export const initiateMicrosoftOAuth = async (purpose: string = 'email'): Promise => { + const response = await apiClient.post('/api/oauth/microsoft/initiate/', { purpose }); + return response.data; +}; + +/** + * List OAuth credentials + */ +export const getOAuthCredentials = async (): Promise => { + const response = await apiClient.get('/api/oauth/credentials/'); + return response.data; +}; + +/** + * Delete OAuth credential + */ +export const deleteOAuthCredential = async (id: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.delete(`/api/oauth/credentials/${id}/`); + return response.data; +}; diff --git a/frontend/src/components/EmailConfigWizard.tsx b/frontend/src/components/EmailConfigWizard.tsx new file mode 100644 index 0000000..2a4b33a --- /dev/null +++ b/frontend/src/components/EmailConfigWizard.tsx @@ -0,0 +1,1004 @@ +/** + * Email Configuration Wizard Component + * + * A step-by-step wizard for configuring email settings with: + * - Auto-detection of email provider (Gmail, Outlook, etc.) + * - OAuth authentication for Google and Microsoft + * - Manual IMAP/SMTP configuration fallback + * - Connection testing + */ + +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Mail, + Loader2, + CheckCircle, + AlertCircle, + ArrowRight, + ArrowLeft, + Settings, + Key, + Shield, + Server, + Eye, + EyeOff, + ExternalLink, + Zap, +} from 'lucide-react'; +import { + useDetectEmailProvider, + useUpdateTicketEmailSettings, + useTestImapConnection, + useTestSmtpConnection, + useOAuthStatus, + useInitiateGoogleOAuth, + useInitiateMicrosoftOAuth, + EmailProviderDetectResult, +} from '../hooks/useTicketEmailSettings'; + +type WizardStep = 'email' | 'auth-method' | 'oauth' | 'manual' | 'test' | 'complete'; + +interface EmailConfigWizardProps { + onComplete?: () => void; + onCancel?: () => void; + initialEmail?: string; +} + +// Provider icons/logos (using colored backgrounds as placeholders) +const ProviderIcon: React.FC<{ provider: string }> = ({ provider }) => { + const colors: Record = { + google: 'bg-red-500', + microsoft: 'bg-blue-600', + yahoo: 'bg-purple-600', + apple: 'bg-gray-800', + unknown: 'bg-gray-400', + }; + + const labels: Record = { + google: 'G', + microsoft: 'M', + yahoo: 'Y', + apple: 'A', + unknown: '?', + }; + + return ( +
+ {labels[provider] || '?'} +
+ ); +}; + +const EmailConfigWizard: React.FC = ({ + onComplete, + onCancel, + initialEmail = '', +}) => { + const { t } = useTranslation(); + + // Wizard state + const [currentStep, setCurrentStep] = useState('email'); + const [email, setEmail] = useState(initialEmail); + const [detectedProvider, setDetectedProvider] = useState(null); + const [authMethod, setAuthMethod] = useState<'oauth' | 'manual'>('manual'); + + // Manual config state + const [manualConfig, setManualConfig] = useState({ + imap_host: '', + imap_port: 993, + imap_use_ssl: true, + imap_username: '', + imap_password: '', + smtp_host: '', + smtp_port: 587, + smtp_use_tls: true, + smtp_use_ssl: false, + smtp_username: '', + smtp_password: '', + smtp_from_email: '', + smtp_from_name: '', + }); + + const [showImapPassword, setShowImapPassword] = useState(false); + const [showSmtpPassword, setShowSmtpPassword] = useState(false); + + // Mutations + const detectMutation = useDetectEmailProvider(); + const updateMutation = useUpdateTicketEmailSettings(); + const testImapMutation = useTestImapConnection(); + const testSmtpMutation = useTestSmtpConnection(); + + // OAuth + const { data: oauthStatus } = useOAuthStatus(); + const googleOAuthMutation = useInitiateGoogleOAuth(); + const microsoftOAuthMutation = useInitiateMicrosoftOAuth(); + + // Update manual config when provider is detected + useEffect(() => { + if (detectedProvider && detectedProvider.detected) { + setManualConfig((prev) => ({ + ...prev, + imap_host: detectedProvider.imap_host || prev.imap_host, + imap_port: detectedProvider.imap_port || prev.imap_port, + smtp_host: detectedProvider.smtp_host || prev.smtp_host, + smtp_port: detectedProvider.smtp_port || prev.smtp_port, + imap_username: email, + smtp_username: email, + smtp_from_email: email, + })); + } + }, [detectedProvider, email]); + + // Step 1: Detect email provider + const handleDetectProvider = async () => { + if (!email || !email.includes('@')) return; + + try { + const result = await detectMutation.mutateAsync(email); + setDetectedProvider(result); + + if (result.oauth_supported) { + setAuthMethod('oauth'); + } else { + setAuthMethod('manual'); + } + + setCurrentStep('auth-method'); + } catch { + // If detection fails, go to manual config + setAuthMethod('manual'); + setCurrentStep('auth-method'); + } + }; + + // Step 2: Choose auth method + const handleAuthMethodSelect = (method: 'oauth' | 'manual') => { + setAuthMethod(method); + if (method === 'oauth') { + setCurrentStep('oauth'); + } else { + setCurrentStep('manual'); + } + }; + + // Step 3a: OAuth flow - redirect to provider's auth page + const handleOAuthConnect = async () => { + if (!detectedProvider) return; + + try { + let result; + + if (detectedProvider.provider === 'google') { + // Check if Google OAuth is configured + if (!oauthStatus?.google?.configured) { + alert('Google OAuth is not configured. Please contact your administrator or use manual configuration.'); + setCurrentStep('manual'); + return; + } + result = await googleOAuthMutation.mutateAsync('email'); + } else if (detectedProvider.provider === 'microsoft') { + // Check if Microsoft OAuth is configured + if (!oauthStatus?.microsoft?.configured) { + alert('Microsoft OAuth is not configured. Please contact your administrator or use manual configuration.'); + setCurrentStep('manual'); + return; + } + result = await microsoftOAuthMutation.mutateAsync('email'); + } else { + // Unsupported OAuth provider + alert('OAuth is not supported for this provider. Please use manual configuration.'); + setCurrentStep('manual'); + return; + } + + if (result.success && result.authorization_url) { + // Redirect to OAuth provider + window.location.href = result.authorization_url; + } else { + alert(result.error || 'Failed to initiate OAuth. Please try manual configuration.'); + setCurrentStep('manual'); + } + } catch { + alert('Failed to initiate OAuth. Please try manual configuration.'); + setCurrentStep('manual'); + } + }; + + // Check if OAuth is available for this provider + const isOAuthAvailable = (provider: string | undefined): boolean => { + if (!provider || !oauthStatus) return false; + if (provider === 'google') return oauthStatus.google?.configured ?? false; + if (provider === 'microsoft') return oauthStatus.microsoft?.configured ?? false; + return false; + }; + + // Step 3b: Save manual configuration + const handleSaveManualConfig = async () => { + try { + await updateMutation.mutateAsync({ + ...manualConfig, + support_email_address: email, + }); + setCurrentStep('test'); + } catch { + // Error is handled by mutation + } + }; + + // Step 4: Test connections + const handleTestConnections = async () => { + await Promise.all([testImapMutation.mutateAsync(), testSmtpMutation.mutateAsync()]); + }; + + // Step navigation + const goBack = () => { + switch (currentStep) { + case 'auth-method': + setCurrentStep('email'); + break; + case 'oauth': + case 'manual': + setCurrentStep('auth-method'); + break; + case 'test': + setCurrentStep('manual'); + break; + case 'complete': + setCurrentStep('test'); + break; + } + }; + + // Progress indicator + const steps = ['email', 'auth-method', authMethod === 'oauth' ? 'oauth' : 'manual', 'test']; + const currentStepIndex = steps.indexOf(currentStep); + const progress = ((currentStepIndex + 1) / steps.length) * 100; + + return ( +
+ {/* Header */} +
+
+

+ + {t('emailWizard.title', 'Configure Email Settings')} +

+ {onCancel && ( + + )} +
+ + {/* Progress bar */} +
+
+
+
+ + {/* Content */} +
+ {/* Step 1: Enter Email */} + {currentStep === 'email' && ( +
+
+

+ {t('emailWizard.step1.title', 'Enter your support email address')} +

+

+ {t( + 'emailWizard.step1.description', + "We'll auto-detect your email provider and configure the optimal settings." + )} +

+
+ +
+ +
+ + setEmail(e.target.value)} + placeholder="support@yourcompany.com" + className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent" + onKeyPress={(e) => e.key === 'Enter' && handleDetectProvider()} + /> +
+
+ + +
+ )} + + {/* Step 2: Choose Auth Method */} + {currentStep === 'auth-method' && ( +
+ {/* Detection result */} + {detectedProvider && ( +
+
+ +
+

+ {detectedProvider.display_name} +

+

+ {detectedProvider.detected + ? t('emailWizard.providerDetected', 'Provider detected automatically') + : t('emailWizard.providerUnknown', 'Provider could not be auto-detected')} +

+
+ {detectedProvider.detected && ( + + )} +
+
+ )} + +
+

+ {t('emailWizard.step2.title', 'Choose authentication method')} +

+

+ {t( + 'emailWizard.step2.description', + 'Select how you want to authenticate with your email provider.' + )} +

+
+ +
+ {/* OAuth option */} + + + {/* Manual option */} + +
+ + +
+ )} + + {/* Step 3a: OAuth */} + {currentStep === 'oauth' && ( +
+
+

+ {t('emailWizard.step3oauth.title', 'Connect with {{provider}}', { + provider: detectedProvider?.display_name || 'OAuth', + })} +

+

+ {t( + 'emailWizard.step3oauth.description', + "You'll be redirected to sign in and grant email access." + )} +

+
+ +
+
+ +
+

+ {t( + 'emailWizard.oauth.benefits', + 'OAuth provides enhanced security without storing your password. Access can be revoked at any time from your account settings.' + )} +

+
+
+
+ + + + +
+ )} + + {/* Step 3b: Manual Configuration */} + {currentStep === 'manual' && ( +
+
+

+ {t('emailWizard.step3manual.title', 'Configure IMAP & SMTP')} +

+

+ {detectedProvider?.detected + ? t( + 'emailWizard.step3manual.prefilled', + "We've pre-filled settings for {{provider}}. Just add your password.", + { provider: detectedProvider.display_name } + ) + : t( + 'emailWizard.step3manual.description', + 'Enter your email server settings below.' + )} +

+
+ + {detectedProvider?.notes && ( +
+

+ + {detectedProvider.notes} +

+
+ )} + + {/* IMAP Settings */} +
+

+ + {t('emailWizard.imap.title', 'IMAP (Incoming Mail)')} +

+ +
+
+ + + setManualConfig((prev) => ({ ...prev, imap_host: e.target.value })) + } + placeholder="imap.example.com" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+
+ + + setManualConfig((prev) => ({ + ...prev, + imap_port: parseInt(e.target.value) || 993, + })) + } + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+ +
+
+ +
+ + + setManualConfig((prev) => ({ ...prev, imap_username: e.target.value })) + } + placeholder={email} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ +
+ + setManualConfig((prev) => ({ ...prev, imap_password: e.target.value })) + } + placeholder="Enter password" + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> + +
+
+
+
+ + {/* SMTP Settings */} +
+

+ + {t('emailWizard.smtp.title', 'SMTP (Outgoing Mail)')} +

+ +
+
+ + + setManualConfig((prev) => ({ ...prev, smtp_host: e.target.value })) + } + placeholder="smtp.example.com" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+
+ + + setManualConfig((prev) => ({ + ...prev, + smtp_port: parseInt(e.target.value) || 587, + })) + } + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+ +
+
+ +
+
+ +
+ + + setManualConfig((prev) => ({ ...prev, smtp_username: e.target.value })) + } + placeholder={email} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ +
+ + setManualConfig((prev) => ({ ...prev, smtp_password: e.target.value })) + } + placeholder="Enter password" + className="w-full px-3 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> + +
+
+ +
+ + + setManualConfig((prev) => ({ ...prev, smtp_from_email: e.target.value })) + } + placeholder={email} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ + + setManualConfig((prev) => ({ ...prev, smtp_from_name: e.target.value })) + } + placeholder="Support Team" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+
+
+ + {/* Error display */} + {updateMutation.isError && ( +
+

+ + {t('emailWizard.saveError', 'Failed to save settings. Please try again.')} +

+
+ )} + +
+ + + +
+
+ )} + + {/* Step 4: Test Connections */} + {currentStep === 'test' && ( +
+
+

+ {t('emailWizard.step4.title', 'Test your email connections')} +

+

+ {t( + 'emailWizard.step4.description', + "Let's verify that both incoming and outgoing email are working correctly." + )} +

+
+ +
+ {/* IMAP Test Result */} +
+
+
+ +
+

+ {t('emailWizard.test.imap', 'IMAP Connection')} +

+

+ {t('emailWizard.test.imapDesc', 'Incoming mail server')} +

+
+
+
+ {testImapMutation.isPending && ( + + )} + {testImapMutation.isSuccess && testImapMutation.data?.success && ( + + )} + {testImapMutation.isSuccess && !testImapMutation.data?.success && ( + + )} + {testImapMutation.isError && ( + + )} +
+
+ {testImapMutation.isSuccess && ( +

+ {testImapMutation.data?.message} +

+ )} +
+ + {/* SMTP Test Result */} +
+
+
+ +
+

+ {t('emailWizard.test.smtp', 'SMTP Connection')} +

+

+ {t('emailWizard.test.smtpDesc', 'Outgoing mail server')} +

+
+
+
+ {testSmtpMutation.isPending && ( + + )} + {testSmtpMutation.isSuccess && testSmtpMutation.data?.success && ( + + )} + {testSmtpMutation.isSuccess && !testSmtpMutation.data?.success && ( + + )} + {testSmtpMutation.isError && ( + + )} +
+
+ {testSmtpMutation.isSuccess && ( +

+ {testSmtpMutation.data?.message} +

+ )} +
+
+ +
+ + +
+ + + +
+
+
+ )} + + {/* Step 5: Complete */} + {currentStep === 'complete' && ( +
+
+ +
+

+ {t('emailWizard.success.title', 'Email configured successfully!')} +

+

+ {t( + 'emailWizard.success.description', + 'Your support email is now connected and ready to process incoming messages.' + )} +

+ +
+ )} +
+
+ ); +}; + +export default EmailConfigWizard; diff --git a/frontend/src/hooks/useTicketEmailSettings.ts b/frontend/src/hooks/useTicketEmailSettings.ts index 90ba13c..412c372 100644 --- a/frontend/src/hooks/useTicketEmailSettings.ts +++ b/frontend/src/hooks/useTicketEmailSettings.ts @@ -11,9 +11,19 @@ import { fetchEmailsNow, getIncomingEmails, reprocessIncomingEmail, + detectEmailProvider, + getOAuthStatus, + initiateGoogleOAuth, + initiateMicrosoftOAuth, + getOAuthCredentials, + deleteOAuthCredential, TicketEmailSettings, TicketEmailSettingsUpdate, IncomingTicketEmail, + EmailProviderDetectResult, + OAuthStatusResult, + OAuthInitiateResult, + OAuthCredential, } from '../api/ticketEmailSettings'; const QUERY_KEY = 'ticketEmailSettings'; @@ -103,4 +113,77 @@ export const useReprocessIncomingEmail = () => { }); }; -export type { TicketEmailSettings, TicketEmailSettingsUpdate, IncomingTicketEmail }; +/** + * Hook to detect email provider from email address + */ +export const useDetectEmailProvider = () => { + return useMutation({ + mutationFn: (email: string) => detectEmailProvider(email), + }); +}; + +// OAuth Hooks +const OAUTH_STATUS_KEY = 'oauthStatus'; +const OAUTH_CREDENTIALS_KEY = 'oauthCredentials'; + +/** + * Hook to get OAuth configuration status + */ +export const useOAuthStatus = () => { + return useQuery({ + queryKey: [OAUTH_STATUS_KEY], + queryFn: getOAuthStatus, + }); +}; + +/** + * Hook to initiate Google OAuth flow + */ +export const useInitiateGoogleOAuth = () => { + return useMutation({ + mutationFn: (purpose: string = 'email') => initiateGoogleOAuth(purpose), + }); +}; + +/** + * Hook to initiate Microsoft OAuth flow + */ +export const useInitiateMicrosoftOAuth = () => { + return useMutation({ + mutationFn: (purpose: string = 'email') => initiateMicrosoftOAuth(purpose), + }); +}; + +/** + * Hook to list OAuth credentials + */ +export const useOAuthCredentials = () => { + return useQuery({ + queryKey: [OAUTH_CREDENTIALS_KEY], + queryFn: getOAuthCredentials, + }); +}; + +/** + * Hook to delete OAuth credential + */ +export const useDeleteOAuthCredential = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: number) => deleteOAuthCredential(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [OAUTH_CREDENTIALS_KEY] }); + }, + }); +}; + +export type { + TicketEmailSettings, + TicketEmailSettingsUpdate, + IncomingTicketEmail, + EmailProviderDetectResult, + OAuthStatusResult, + OAuthInitiateResult, + OAuthCredential, +}; diff --git a/frontend/src/pages/platform/PlatformSettings.tsx b/frontend/src/pages/platform/PlatformSettings.tsx index 86a9441..1ed4f2b 100644 --- a/frontend/src/pages/platform/PlatformSettings.tsx +++ b/frontend/src/pages/platform/PlatformSettings.tsx @@ -55,7 +55,8 @@ import { useTestSmtpConnection, useFetchEmailsNow, } from '../../hooks/useTicketEmailSettings'; -import { Send } from 'lucide-react'; +import { Send, Wand2 } from 'lucide-react'; +import EmailConfigWizard from '../../components/EmailConfigWizard'; type TabType = 'general' | 'stripe' | 'tiers' | 'oauth'; @@ -119,12 +120,14 @@ const PlatformSettings: React.FC = () => { const GeneralSettingsTab: React.FC = () => { const { t } = useTranslation(); - const { data: emailSettings, isLoading, error } = useTicketEmailSettings(); + const { data: emailSettings, isLoading, error, refetch } = useTicketEmailSettings(); const updateMutation = useUpdateTicketEmailSettings(); const testImapMutation = useTestImapConnection(); const testSmtpMutation = useTestSmtpConnection(); const fetchNowMutation = useFetchEmailsNow(); + const [showWizard, setShowWizard] = useState(false); + const [formData, setFormData] = useState({ // IMAP settings imap_host: '', @@ -228,14 +231,39 @@ const GeneralSettingsTab: React.FC = () => { ); } + // Show wizard if requested + if (showWizard) { + return ( +
+ { + setShowWizard(false); + refetch(); + }} + onCancel={() => setShowWizard(false)} + initialEmail={emailSettings?.imap_username || ''} + /> +
+ ); + } + return (
{/* Email Processing Status */}
-

- - {t('platform.settings.emailProcessing', 'Support Email Processing')} -

+
+

+ + {t('platform.settings.emailProcessing', 'Support Email Processing')} +

+ +
diff --git a/resize_logo.py b/resize_logo.py new file mode 100644 index 0000000..70bf712 --- /dev/null +++ b/resize_logo.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Script to resize the SmoothSchedule logo to 120x120 pixels. +Maintains aspect ratio and centers on a transparent canvas. +Usage: python resize_logo.py +""" + +from PIL import Image +from pathlib import Path + +# Paths +PROJECT_ROOT = Path(__file__).parent +SOURCE_LOGO = PROJECT_ROOT / "frontend" / "src" / "assets" / "smooth_schedule_icon.png" +OUTPUT_LOGO = PROJECT_ROOT / "smooth_schedule_logo_120x120.png" + +def resize_logo(source: Path, output: Path, size: tuple = (120, 120)): + """Resize logo to fit within specified dimensions, maintaining aspect ratio.""" + if not source.exists(): + print(f"Error: Source logo not found at {source}") + return False + + try: + with Image.open(source) as img: + # Convert to RGBA if needed (preserves transparency) + if img.mode != 'RGBA': + img = img.convert('RGBA') + + original_size = img.size + + # Calculate scaling to fit within the target size while maintaining aspect ratio + width_ratio = size[0] / img.width + height_ratio = size[1] / img.height + scale = min(width_ratio, height_ratio) + + new_width = int(img.width * scale) + new_height = int(img.height * scale) + + # Resize maintaining aspect ratio + resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Create a transparent canvas of the target size + canvas = Image.new('RGBA', size, (0, 0, 0, 0)) + + # Center the resized image on the canvas + x_offset = (size[0] - new_width) // 2 + y_offset = (size[1] - new_height) // 2 + canvas.paste(resized, (x_offset, y_offset)) + + # Save the final image + canvas.save(output, 'PNG', optimize=True) + + print(f"Successfully resized logo:") + print(f" Source: {source}") + print(f" Output: {output}") + print(f" Original size: {original_size}") + print(f" Scaled size: {new_width}x{new_height}") + print(f" Canvas size: {size}") + return True + + except Exception as e: + print(f"Error resizing logo: {e}") + return False + +if __name__ == "__main__": + resize_logo(SOURCE_LOGO, OUTPUT_LOGO) diff --git a/smooth_schedule_logo_120x120.png b/smooth_schedule_logo_120x120.png new file mode 100644 index 0000000..66b4bcc Binary files /dev/null and b/smooth_schedule_logo_120x120.png differ diff --git a/smoothschedule/compose/production/traefik/traefik.yml b/smoothschedule/compose/production/traefik/traefik.yml index a9c2d2f..aff4eab 100644 --- a/smoothschedule/compose/production/traefik/traefik.yml +++ b/smoothschedule/compose/production/traefik/traefik.yml @@ -24,12 +24,13 @@ certificatesResolvers: acme: email: 'admin@smoothschedule.com' storage: /etc/traefik/acme/acme.json - # https://doc.traefik.io/traefik/https/acme/#httpchallenge + # HTTP challenge for individual domains httpChallenge: entryPoint: web http: routers: + # Main domain and www web-secure-router: rule: 'Host(`smoothschedule.com`) || Host(`www.smoothschedule.com`)' entryPoints: @@ -38,7 +39,40 @@ http: - csrf service: django tls: - # https://doc.traefik.io/traefik/routing/routers/#certresolver + certResolver: letsencrypt + + # Platform subdomain (admin dashboard) + platform-router: + rule: 'Host(`platform.smoothschedule.com`)' + entryPoints: + - web-secure + middlewares: + - csrf + service: django + tls: + certResolver: letsencrypt + + # API subdomain + api-router: + rule: 'Host(`api.smoothschedule.com`)' + entryPoints: + - web-secure + middlewares: + - csrf + service: django + tls: + certResolver: letsencrypt + + # Wildcard subdomain router for tenant subdomains + # Each subdomain gets its own certificate via HTTP challenge + subdomain-router: + rule: 'HostRegexp(`{subdomain:[a-z0-9-]+}.smoothschedule.com`)' + entryPoints: + - web-secure + middlewares: + - csrf + service: django + tls: certResolver: letsencrypt flower-secure-router: @@ -47,7 +81,6 @@ http: - flower service: flower tls: - # https://doc.traefik.io/traefik/master/routing/routers/#certresolver certResolver: letsencrypt middlewares: diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py index 8be58f9..072aea9 100644 --- a/smoothschedule/config/settings/base.py +++ b/smoothschedule/config/settings/base.py @@ -353,3 +353,19 @@ STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", default=False) DJSTRIPE_WEBHOOK_SECRET = env("STRIPE_WEBHOOK_SECRET", default="") DJSTRIPE_USE_NATIVE_JSONFIELD = True DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id" + +# OAuth for Email Integration (IMAP/SMTP with XOAUTH2) +# ------------------------------------------------------------------------------ +# Google OAuth (Gmail, Google Workspace) +# Create credentials at: https://console.cloud.google.com/apis/credentials +GOOGLE_OAUTH_CLIENT_ID = env("GOOGLE_OAUTH_CLIENT_ID", default="") +GOOGLE_OAUTH_CLIENT_SECRET = env("GOOGLE_OAUTH_CLIENT_SECRET", default="") + +# Microsoft OAuth (Outlook, Office 365) +# Create app at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade +MICROSOFT_OAUTH_CLIENT_ID = env("MICROSOFT_OAUTH_CLIENT_ID", default="") +MICROSOFT_OAUTH_CLIENT_SECRET = env("MICROSOFT_OAUTH_CLIENT_SECRET", default="") +MICROSOFT_OAUTH_TENANT_ID = env("MICROSOFT_OAUTH_TENANT_ID", default="common") + +# Frontend URL (for OAuth callback redirects) +FRONTEND_URL = env("FRONTEND_URL", default="http://platform.lvh.me:5173") diff --git a/smoothschedule/config/settings/production.py b/smoothschedule/config/settings/production.py index d25e96b..0e651c2 100644 --- a/smoothschedule/config/settings/production.py +++ b/smoothschedule/config/settings/production.py @@ -1,12 +1,6 @@ # ruff: noqa: E501 import logging -import sentry_sdk -from sentry_sdk.integrations.celery import CeleryIntegration -from sentry_sdk.integrations.django import DjangoIntegration -from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.redis import RedisIntegration - from .base import * # noqa: F403 from .base import DATABASES from .base import INSTALLED_APPS @@ -72,45 +66,49 @@ SECURE_CONTENT_TYPE_NOSNIFF = env.bool( ) -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID") -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY") -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME") -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_QUERYSTRING_AUTH = False -# DO NOT change these unless you know what you're doing. -_AWS_EXPIRY = 60 * 60 * 24 * 7 -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_S3_OBJECT_PARAMETERS = { - "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate", -} -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_S3_MAX_MEMORY_SIZE = env.int( - "DJANGO_AWS_S3_MAX_MEMORY_SIZE", - default=100_000_000, # 100MB -) -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings -AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None) -# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront -AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None) -aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" # STATIC & MEDIA # ------------------------ -STORAGES = { - "default": { - "BACKEND": "storages.backends.s3.S3Storage", - "OPTIONS": { - "location": "media", - "file_overwrite": False, +# AWS S3 storage is optional - only configure if credentials are provided +AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID", default="") +AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY", default="") +AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME", default="") + +if AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and AWS_STORAGE_BUCKET_NAME: + # Use S3 for media storage + AWS_QUERYSTRING_AUTH = False + _AWS_EXPIRY = 60 * 60 * 24 * 7 + AWS_S3_OBJECT_PARAMETERS = { + "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate", + } + AWS_S3_MAX_MEMORY_SIZE = env.int("DJANGO_AWS_S3_MAX_MEMORY_SIZE", default=100_000_000) + AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None) + AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None) + aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" + + STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + "location": "media", + "file_overwrite": False, + }, }, - }, - "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", - }, -} -MEDIA_URL = f"https://{aws_s3_domain}/media/" + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, + } + MEDIA_URL = f"https://{aws_s3_domain}/media/" +else: + # Use local filesystem storage + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, + } + MEDIA_URL = "/media/" # EMAIL # ------------------------------------------------------------------------------ @@ -133,19 +131,25 @@ ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX # Django Admin URL regex. ADMIN_URL = env("DJANGO_ADMIN_URL") -# Anymail +# Anymail (optional - falls back to console email if not configured) # ------------------------------------------------------------------------------ -# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail -INSTALLED_APPS += ["anymail"] -# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend -# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference -# https://anymail.readthedocs.io/en/stable/esps/mailgun/ -EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" -ANYMAIL = { - "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), - "MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"), - "MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"), -} +MAILGUN_API_KEY = env("MAILGUN_API_KEY", default="") +MAILGUN_DOMAIN = env("MAILGUN_DOMAIN", default="") + +if MAILGUN_API_KEY and MAILGUN_DOMAIN: + INSTALLED_APPS += ["anymail"] + EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" + ANYMAIL = { + "MAILGUN_API_KEY": MAILGUN_API_KEY, + "MAILGUN_SENDER_DOMAIN": MAILGUN_DOMAIN, + "MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"), + } +else: + # Fall back to SMTP or console email + EMAIL_BACKEND = env( + "DJANGO_EMAIL_BACKEND", + default="django.core.mail.backends.console.EmailBackend" + ) # LOGGING @@ -186,27 +190,35 @@ LOGGING = { }, } -# Sentry +# Sentry (optional) # ------------------------------------------------------------------------------ -SENTRY_DSN = env("SENTRY_DSN") -SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO) +SENTRY_DSN = env("SENTRY_DSN", default="") -sentry_logging = LoggingIntegration( - level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs - event_level=logging.ERROR, # Send errors as events -) -integrations = [ - sentry_logging, - DjangoIntegration(), - CeleryIntegration(), - RedisIntegration(), -] -sentry_sdk.init( - dsn=SENTRY_DSN, - integrations=integrations, - environment=env("SENTRY_ENVIRONMENT", default="production"), - traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), -) +if SENTRY_DSN: + import sentry_sdk + from sentry_sdk.integrations.celery import CeleryIntegration + from sentry_sdk.integrations.django import DjangoIntegration + from sentry_sdk.integrations.logging import LoggingIntegration + from sentry_sdk.integrations.redis import RedisIntegration + + SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO) + + sentry_logging = LoggingIntegration( + level=SENTRY_LOG_LEVEL, + event_level=logging.ERROR, + ) + integrations = [ + sentry_logging, + DjangoIntegration(), + CeleryIntegration(), + RedisIntegration(), + ] + sentry_sdk.init( + dsn=SENTRY_DSN, + integrations=integrations, + environment=env("SENTRY_ENVIRONMENT", default="production"), + traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), + ) # django-rest-framework # ------------------------------------------------------------------------------- diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index c08e499..b82736b 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -61,6 +61,8 @@ urlpatterns += [ path("api/notifications/", include("notifications.urls")), # Platform API path("api/platform/", include("platform_admin.urls", namespace="platform")), + # OAuth Email Integration API + path("api/oauth/", include("core.oauth_urls", namespace="oauth")), # Auth API path("api/auth-token/", csrf_exempt(obtain_auth_token), name="obtain_auth_token"), path("api/auth/login/", login_view, name="login"), diff --git a/smoothschedule/core/migrations/0010_add_oauth_credential_model.py b/smoothschedule/core/migrations/0010_add_oauth_credential_model.py new file mode 100644 index 0000000..53301c2 --- /dev/null +++ b/smoothschedule/core/migrations/0010_add_oauth_credential_model.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.8 on 2025-11-30 00:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_add_feature_limits'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='OAuthCredential', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider', models.CharField(choices=[('google', 'Google'), ('microsoft', 'Microsoft')], help_text='OAuth provider (google, microsoft)', max_length=20)), + ('purpose', models.CharField(choices=[('email', 'Email Access (IMAP/SMTP)'), ('calendar', 'Calendar Sync'), ('drive', 'File Storage')], help_text='What this credential is used for', max_length=20)), + ('email', models.EmailField(help_text='Email address associated with this OAuth credential', max_length=254)), + ('access_token', models.TextField(help_text='OAuth access token')), + ('refresh_token', models.TextField(blank=True, default='', help_text='OAuth refresh token (for token renewal)')), + ('token_expiry', models.DateTimeField(blank=True, help_text='When the access token expires', null=True)), + ('scopes', models.JSONField(blank=True, default=list, help_text='OAuth scopes granted by the user')), + ('is_valid', models.BooleanField(default=True, help_text='Whether this credential is currently valid')), + ('last_used_at', models.DateTimeField(blank=True, help_text='When this credential was last used', null=True)), + ('last_error', models.TextField(blank=True, default='', help_text='Last error message if token refresh failed')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('authorized_by', models.ForeignKey(blank=True, help_text='User who authorized this OAuth connection.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorized_oauth_credentials', to=settings.AUTH_USER_MODEL)), + ('tenant', models.ForeignKey(blank=True, help_text='Tenant this credential belongs to. Null for platform-level credentials.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_oauth_credentials', to='core.tenant')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['tenant', 'provider', 'purpose'], name='core_oauthc_tenant__1c2893_idx'), models.Index(fields=['email'], name='core_oauthc_email_dab500_idx')], + 'unique_together': {('tenant', 'email', 'purpose')}, + }, + ), + ] diff --git a/smoothschedule/core/models.py b/smoothschedule/core/models.py index 92e253b..ac69705 100644 --- a/smoothschedule/core/models.py +++ b/smoothschedule/core/models.py @@ -391,6 +391,169 @@ class PermissionGrant(models.Model): ) +class OAuthCredential(models.Model): + """ + Reusable OAuth credentials for service-level integrations. + + Used for: + - Email access (Gmail XOAUTH2, Microsoft XOAUTH2) + - Calendar sync (Google Calendar, Outlook Calendar) + - Other API integrations requiring OAuth tokens + + Supports on-demand token refresh when tokens expire. + """ + + class Provider(models.TextChoices): + GOOGLE = 'google', 'Google' + MICROSOFT = 'microsoft', 'Microsoft' + + class Purpose(models.TextChoices): + EMAIL = 'email', 'Email Access (IMAP/SMTP)' + CALENDAR = 'calendar', 'Calendar Sync' + DRIVE = 'drive', 'File Storage' + + # Owner - can be platform-level (null tenant) or tenant-specific + tenant = models.ForeignKey( + 'core.Tenant', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='service_oauth_credentials', + help_text="Tenant this credential belongs to. Null for platform-level credentials." + ) + + # Which user authorized this (for audit/revocation) + authorized_by = models.ForeignKey( + 'users.User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='authorized_oauth_credentials', + help_text="User who authorized this OAuth connection." + ) + + # OAuth provider and purpose + provider = models.CharField( + max_length=20, + choices=Provider.choices, + help_text="OAuth provider (google, microsoft)" + ) + purpose = models.CharField( + max_length=20, + choices=Purpose.choices, + help_text="What this credential is used for" + ) + + # The email/account this credential is for + email = models.EmailField( + help_text="Email address associated with this OAuth credential" + ) + + # OAuth tokens (should be encrypted at rest in production) + access_token = models.TextField( + help_text="OAuth access token" + ) + refresh_token = models.TextField( + blank=True, + default='', + help_text="OAuth refresh token (for token renewal)" + ) + token_expiry = models.DateTimeField( + null=True, + blank=True, + help_text="When the access token expires" + ) + + # Scopes granted + scopes = models.JSONField( + default=list, + blank=True, + help_text="OAuth scopes granted by the user" + ) + + # Status tracking + is_valid = models.BooleanField( + default=True, + help_text="Whether this credential is currently valid" + ) + last_used_at = models.DateTimeField( + null=True, + blank=True, + help_text="When this credential was last used" + ) + last_error = models.TextField( + blank=True, + default='', + help_text="Last error message if token refresh failed" + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['tenant', 'provider', 'purpose']), + models.Index(fields=['email']), + ] + # One credential per email/purpose combination per tenant + unique_together = [['tenant', 'email', 'purpose']] + + def __str__(self): + scope = self.tenant.name if self.tenant else "Platform" + return f"{self.email} ({self.get_provider_display()} - {self.get_purpose_display()}) [{scope}]" + + def is_expired(self): + """Check if the access token has expired.""" + if not self.token_expiry: + return False + return timezone.now() >= self.token_expiry + + def is_expiring_soon(self, minutes=5): + """Check if the access token will expire within the given minutes.""" + if not self.token_expiry: + return False + threshold = timezone.now() + timedelta(minutes=minutes) + return threshold >= self.token_expiry + + def needs_refresh(self): + """Check if this credential needs token refresh.""" + return self.is_expired() or self.is_expiring_soon() + + def mark_used(self): + """Update last_used_at timestamp.""" + self.last_used_at = timezone.now() + self.save(update_fields=['last_used_at']) + + def mark_invalid(self, error_message=''): + """Mark this credential as invalid (e.g., after refresh failure).""" + self.is_valid = False + self.last_error = error_message + self.save(update_fields=['is_valid', 'last_error', 'updated_at']) + + def update_tokens(self, access_token, refresh_token=None, expires_in=None): + """ + Update tokens after a successful refresh. + + Args: + access_token: New access token + refresh_token: New refresh token (optional, some providers rotate) + expires_in: Token lifetime in seconds + """ + self.access_token = access_token + if refresh_token: + self.refresh_token = refresh_token + if expires_in: + self.token_expiry = timezone.now() + timedelta(seconds=expires_in) + self.is_valid = True + self.last_error = '' + self.save(update_fields=[ + 'access_token', 'refresh_token', 'token_expiry', + 'is_valid', 'last_error', 'updated_at' + ]) + + class TierLimit(models.Model): """ Defines resource limits for each subscription tier. diff --git a/smoothschedule/core/oauth_service.py b/smoothschedule/core/oauth_service.py new file mode 100644 index 0000000..382c97f --- /dev/null +++ b/smoothschedule/core/oauth_service.py @@ -0,0 +1,406 @@ +""" +OAuth Service for Email Integration + +Handles OAuth flows for Google and Microsoft to obtain tokens for +IMAP/SMTP XOAUTH2 authentication. + +Google OAuth: +- Uses google-auth-oauthlib for OAuth 2.0 flow +- Scopes: Gmail IMAP and SMTP access +- Supports on-demand token refresh + +Microsoft OAuth: +- Uses MSAL (Microsoft Authentication Library) +- Scopes: IMAP.AccessAsUser.All, SMTP.Send +- Supports on-demand token refresh +""" + +import logging +from datetime import timedelta +from typing import Optional +from urllib.parse import urlencode + +from django.conf import settings +from django.utils import timezone + +from google.oauth2.credentials import Credentials as GoogleCredentials +from google_auth_oauthlib.flow import Flow as GoogleFlow +from google.auth.transport.requests import Request as GoogleAuthRequest + +import msal + +from .models import OAuthCredential + +logger = logging.getLogger(__name__) + + +# Google OAuth scopes for email access +GOOGLE_SCOPES = [ + 'https://mail.google.com/', # Full Gmail access (IMAP, SMTP) + 'openid', + 'https://www.googleapis.com/auth/userinfo.email', +] + +# Microsoft OAuth scopes for email access +MICROSOFT_SCOPES = [ + 'https://outlook.office.com/IMAP.AccessAsUser.All', + 'https://outlook.office.com/SMTP.Send', + 'offline_access', # For refresh tokens + 'openid', + 'email', +] + + +class GoogleOAuthService: + """ + Service for handling Google OAuth flow for email access. + """ + + def __init__(self): + self.client_id = getattr(settings, 'GOOGLE_OAUTH_CLIENT_ID', '') + self.client_secret = getattr(settings, 'GOOGLE_OAUTH_CLIENT_SECRET', '') + + def is_configured(self) -> bool: + """Check if Google OAuth is configured.""" + return bool(self.client_id and self.client_secret) + + def get_authorization_url(self, redirect_uri: str, state: str) -> str: + """ + Generate the Google OAuth authorization URL. + + Args: + redirect_uri: The callback URL after authorization + state: CSRF state token + + Returns: + Authorization URL to redirect user to + """ + if not self.is_configured(): + raise ValueError("Google OAuth is not configured") + + flow = GoogleFlow.from_client_config( + { + 'web': { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + } + }, + scopes=GOOGLE_SCOPES, + redirect_uri=redirect_uri, + ) + + authorization_url, _ = flow.authorization_url( + access_type='offline', # Get refresh token + include_granted_scopes='true', + state=state, + prompt='consent', # Force consent to get refresh token + ) + + return authorization_url + + def exchange_code_for_tokens( + self, code: str, redirect_uri: str + ) -> dict: + """ + Exchange authorization code for access and refresh tokens. + + Args: + code: Authorization code from callback + redirect_uri: Same redirect URI used in authorization + + Returns: + Dict with access_token, refresh_token, expires_in, email + """ + if not self.is_configured(): + raise ValueError("Google OAuth is not configured") + + flow = GoogleFlow.from_client_config( + { + 'web': { + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + } + }, + scopes=GOOGLE_SCOPES, + redirect_uri=redirect_uri, + ) + + flow.fetch_token(code=code) + credentials = flow.credentials + + # Get user email from ID token + from google.oauth2 import id_token + from google.auth.transport import requests + + try: + id_info = id_token.verify_oauth2_token( + credentials.id_token, + requests.Request(), + self.client_id + ) + email = id_info.get('email', '') + except Exception as e: + logger.warning(f"Could not extract email from ID token: {e}") + email = '' + + return { + 'access_token': credentials.token, + 'refresh_token': credentials.refresh_token, + 'expires_in': (credentials.expiry - timezone.now()).total_seconds() if credentials.expiry else 3600, + 'email': email, + 'scopes': list(credentials.scopes) if credentials.scopes else GOOGLE_SCOPES, + } + + def refresh_token(self, credential: OAuthCredential) -> bool: + """ + Refresh an expired Google OAuth token. + + Args: + credential: OAuthCredential instance to refresh + + Returns: + True if refresh successful, False otherwise + """ + if not credential.refresh_token: + logger.error(f"No refresh token for credential {credential.id}") + credential.mark_invalid("No refresh token available") + return False + + try: + creds = GoogleCredentials( + token=credential.access_token, + refresh_token=credential.refresh_token, + token_uri='https://oauth2.googleapis.com/token', + client_id=self.client_id, + client_secret=self.client_secret, + ) + + creds.refresh(GoogleAuthRequest()) + + # Calculate expires_in from expiry datetime + expires_in = None + if creds.expiry: + expires_in = int((creds.expiry - timezone.now()).total_seconds()) + + credential.update_tokens( + access_token=creds.token, + refresh_token=creds.refresh_token, # May be rotated + expires_in=expires_in or 3600, + ) + + logger.info(f"Successfully refreshed Google token for {credential.email}") + return True + + except Exception as e: + logger.error(f"Failed to refresh Google token for {credential.email}: {e}") + credential.mark_invalid(str(e)) + return False + + +class MicrosoftOAuthService: + """ + Service for handling Microsoft OAuth flow for email access. + Uses MSAL (Microsoft Authentication Library). + """ + + def __init__(self): + self.client_id = getattr(settings, 'MICROSOFT_OAUTH_CLIENT_ID', '') + self.client_secret = getattr(settings, 'MICROSOFT_OAUTH_CLIENT_SECRET', '') + self.tenant_id = getattr(settings, 'MICROSOFT_OAUTH_TENANT_ID', 'common') + + def is_configured(self) -> bool: + """Check if Microsoft OAuth is configured.""" + return bool(self.client_id and self.client_secret) + + @property + def authority(self) -> str: + """Get the Microsoft authority URL.""" + return f'https://login.microsoftonline.com/{self.tenant_id}' + + def _get_msal_app(self) -> msal.ConfidentialClientApplication: + """Create MSAL application instance.""" + return msal.ConfidentialClientApplication( + self.client_id, + authority=self.authority, + client_credential=self.client_secret, + ) + + def get_authorization_url(self, redirect_uri: str, state: str) -> str: + """ + Generate the Microsoft OAuth authorization URL. + + Args: + redirect_uri: The callback URL after authorization + state: CSRF state token + + Returns: + Authorization URL to redirect user to + """ + if not self.is_configured(): + raise ValueError("Microsoft OAuth is not configured") + + app = self._get_msal_app() + + auth_url = app.get_authorization_request_url( + scopes=MICROSOFT_SCOPES, + redirect_uri=redirect_uri, + state=state, + prompt='consent', # Force consent to get refresh token + ) + + return auth_url + + def exchange_code_for_tokens( + self, code: str, redirect_uri: str + ) -> dict: + """ + Exchange authorization code for access and refresh tokens. + + Args: + code: Authorization code from callback + redirect_uri: Same redirect URI used in authorization + + Returns: + Dict with access_token, refresh_token, expires_in, email + """ + if not self.is_configured(): + raise ValueError("Microsoft OAuth is not configured") + + app = self._get_msal_app() + + result = app.acquire_token_by_authorization_code( + code=code, + scopes=MICROSOFT_SCOPES, + redirect_uri=redirect_uri, + ) + + if 'error' in result: + raise ValueError(f"Token exchange failed: {result.get('error_description', result.get('error'))}") + + # Extract email from ID token claims + email = '' + if 'id_token_claims' in result: + email = result['id_token_claims'].get('preferred_username', '') + if not email: + email = result['id_token_claims'].get('email', '') + + return { + 'access_token': result['access_token'], + 'refresh_token': result.get('refresh_token', ''), + 'expires_in': result.get('expires_in', 3600), + 'email': email, + 'scopes': result.get('scope', '').split() if result.get('scope') else MICROSOFT_SCOPES, + } + + def refresh_token(self, credential: OAuthCredential) -> bool: + """ + Refresh an expired Microsoft OAuth token. + + Args: + credential: OAuthCredential instance to refresh + + Returns: + True if refresh successful, False otherwise + """ + if not credential.refresh_token: + logger.error(f"No refresh token for credential {credential.id}") + credential.mark_invalid("No refresh token available") + return False + + try: + app = self._get_msal_app() + + # MSAL handles refresh internally when using acquire_token_by_refresh_token + result = app.acquire_token_by_refresh_token( + refresh_token=credential.refresh_token, + scopes=MICROSOFT_SCOPES, + ) + + if 'error' in result: + raise ValueError(result.get('error_description', result.get('error'))) + + credential.update_tokens( + access_token=result['access_token'], + refresh_token=result.get('refresh_token', credential.refresh_token), + expires_in=result.get('expires_in', 3600), + ) + + logger.info(f"Successfully refreshed Microsoft token for {credential.email}") + return True + + except Exception as e: + logger.error(f"Failed to refresh Microsoft token for {credential.email}: {e}") + credential.mark_invalid(str(e)) + return False + + +def get_oauth_service(provider: str): + """ + Factory function to get the appropriate OAuth service. + + Args: + provider: 'google' or 'microsoft' + + Returns: + OAuth service instance + """ + if provider == 'google': + return GoogleOAuthService() + elif provider == 'microsoft': + return MicrosoftOAuthService() + else: + raise ValueError(f"Unknown OAuth provider: {provider}") + + +def ensure_valid_token(credential: OAuthCredential) -> Optional[str]: + """ + Ensure the OAuth credential has a valid (non-expired) token. + Refreshes if needed. + + Args: + credential: OAuthCredential instance + + Returns: + Valid access token, or None if refresh failed + """ + if not credential.is_valid: + logger.warning(f"Credential {credential.id} is marked invalid") + return None + + if not credential.needs_refresh(): + credential.mark_used() + return credential.access_token + + # Need to refresh + logger.info(f"Refreshing token for {credential.email}") + service = get_oauth_service(credential.provider) + + if service.refresh_token(credential): + credential.mark_used() + return credential.access_token + + return None + + +def generate_xoauth2_string(email: str, access_token: str) -> str: + """ + Generate XOAUTH2 authentication string for IMAP/SMTP. + + The format is: base64("user=" + email + "^Aauth=Bearer " + access_token + "^A^A") + where ^A is ASCII character 0x01 + + Args: + email: User's email address + access_token: OAuth access token + + Returns: + Base64-encoded XOAUTH2 string + """ + import base64 + + auth_string = f'user={email}\x01auth=Bearer {access_token}\x01\x01' + return base64.b64encode(auth_string.encode()).decode() diff --git a/smoothschedule/core/oauth_urls.py b/smoothschedule/core/oauth_urls.py new file mode 100644 index 0000000..348a49e --- /dev/null +++ b/smoothschedule/core/oauth_urls.py @@ -0,0 +1,35 @@ +""" +OAuth URL Configuration + +URL routes for OAuth email integration endpoints. +""" + +from django.urls import path +from .oauth_views import ( + OAuthStatusView, + GoogleOAuthInitiateView, + GoogleOAuthCallbackView, + MicrosoftOAuthInitiateView, + MicrosoftOAuthCallbackView, + OAuthCredentialListView, + OAuthCredentialDeleteView, +) + +app_name = 'oauth' + +urlpatterns = [ + # Status + path('status/', OAuthStatusView.as_view(), name='status'), + + # Google OAuth + path('google/initiate/', GoogleOAuthInitiateView.as_view(), name='google-initiate'), + path('google/callback/', GoogleOAuthCallbackView.as_view(), name='google-callback'), + + # Microsoft OAuth + path('microsoft/initiate/', MicrosoftOAuthInitiateView.as_view(), name='microsoft-initiate'), + path('microsoft/callback/', MicrosoftOAuthCallbackView.as_view(), name='microsoft-callback'), + + # Credential management + path('credentials/', OAuthCredentialListView.as_view(), name='credential-list'), + path('credentials//', OAuthCredentialDeleteView.as_view(), name='credential-delete'), +] diff --git a/smoothschedule/core/oauth_views.py b/smoothschedule/core/oauth_views.py new file mode 100644 index 0000000..30a8d88 --- /dev/null +++ b/smoothschedule/core/oauth_views.py @@ -0,0 +1,405 @@ +""" +OAuth API Views for Email Integration + +Provides endpoints for initiating OAuth flows and handling callbacks +for Google and Microsoft OAuth authentication. +""" + +import logging +import secrets +from urllib.parse import urljoin + +from django.conf import settings +from django.shortcuts import redirect +from django.http import HttpResponse +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from platform_admin.permissions import IsPlatformAdmin +from .models import OAuthCredential +from .oauth_service import ( + GoogleOAuthService, + MicrosoftOAuthService, + get_oauth_service, +) + +logger = logging.getLogger(__name__) + + +def get_oauth_redirect_uri(request, provider: str) -> str: + """ + Build the OAuth callback URL. + + For local development, uses lvh.me domain. + For production, uses the request's host. + """ + # Get the base URL from settings or request + if settings.DEBUG: + base_url = 'http://platform.lvh.me:8000' + else: + scheme = 'https' if request.is_secure() else 'http' + base_url = f'{scheme}://{request.get_host()}' + + return f'{base_url}/api/oauth/{provider}/callback/' + + +class OAuthStatusView(APIView): + """ + Check OAuth configuration status. + + GET /api/oauth/status/ + + Returns which OAuth providers are configured and available. + """ + permission_classes = [IsPlatformAdmin] + + def get(self, request): + google_service = GoogleOAuthService() + microsoft_service = MicrosoftOAuthService() + + return Response({ + 'google': { + 'configured': google_service.is_configured(), + }, + 'microsoft': { + 'configured': microsoft_service.is_configured(), + }, + }) + + +class GoogleOAuthInitiateView(APIView): + """ + Initiate Google OAuth flow for email access. + + POST /api/oauth/google/initiate/ + Body: { "purpose": "email" } + + Returns authorization URL to redirect user to. + """ + permission_classes = [IsPlatformAdmin] + + def post(self, request): + purpose = request.data.get('purpose', 'email') + + service = GoogleOAuthService() + if not service.is_configured(): + return Response({ + 'success': False, + 'error': 'Google OAuth is not configured. Please add GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET to settings.', + }, status=status.HTTP_400_BAD_REQUEST) + + # Generate state token for CSRF protection + state = secrets.token_urlsafe(32) + + # Store state in session + request.session['oauth_state'] = state + request.session['oauth_purpose'] = purpose + request.session['oauth_provider'] = 'google' + + redirect_uri = get_oauth_redirect_uri(request, 'google') + + try: + auth_url = service.get_authorization_url( + redirect_uri=redirect_uri, + state=state, + ) + + return Response({ + 'success': True, + 'authorization_url': auth_url, + }) + + except Exception as e: + logger.error(f"Failed to generate Google OAuth URL: {e}") + return Response({ + 'success': False, + 'error': str(e), + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class GoogleOAuthCallbackView(APIView): + """ + Handle Google OAuth callback. + + GET /api/oauth/google/callback/?code=...&state=... + + Exchanges code for tokens and saves credential. + Redirects to frontend with success/error status. + """ + permission_classes = [] # Public endpoint (callback from Google) + + def get(self, request): + code = request.GET.get('code') + state = request.GET.get('state') + error = request.GET.get('error') + + # Frontend URL to redirect to after OAuth + frontend_base = settings.FRONTEND_URL if hasattr(settings, 'FRONTEND_URL') else 'http://platform.lvh.me:5173' + success_url = f'{frontend_base}/platform/settings?oauth=success&provider=google' + error_url = f'{frontend_base}/platform/settings?oauth=error&provider=google' + + if error: + logger.warning(f"Google OAuth error: {error}") + return redirect(f'{error_url}&message={error}') + + # Verify state + expected_state = request.session.get('oauth_state') + if not state or state != expected_state: + logger.warning("Google OAuth state mismatch") + return redirect(f'{error_url}&message=invalid_state') + + if not code: + return redirect(f'{error_url}&message=no_code') + + service = GoogleOAuthService() + redirect_uri = get_oauth_redirect_uri(request, 'google') + + try: + # Exchange code for tokens + tokens = service.exchange_code_for_tokens( + code=code, + redirect_uri=redirect_uri, + ) + + email = tokens.get('email', '') + if not email: + return redirect(f'{error_url}&message=no_email') + + purpose = request.session.get('oauth_purpose', 'email') + + # Save or update credential + credential, created = OAuthCredential.objects.update_or_create( + tenant=None, # Platform-level credential + email=email, + purpose=purpose, + defaults={ + 'provider': 'google', + 'access_token': tokens['access_token'], + 'refresh_token': tokens.get('refresh_token', ''), + 'token_expiry': tokens.get('expires_in'), + 'scopes': tokens.get('scopes', []), + 'is_valid': True, + 'authorized_by': request.user if request.user.is_authenticated else None, + } + ) + + # Set token expiry properly + if tokens.get('expires_in'): + from django.utils import timezone + from datetime import timedelta + credential.token_expiry = timezone.now() + timedelta(seconds=tokens['expires_in']) + credential.save(update_fields=['token_expiry']) + + # Clear session + request.session.pop('oauth_state', None) + request.session.pop('oauth_purpose', None) + request.session.pop('oauth_provider', None) + + logger.info(f"Successfully stored Google OAuth credential for {email}") + return redirect(f'{success_url}&email={email}') + + except Exception as e: + logger.error(f"Google OAuth callback error: {e}") + return redirect(f'{error_url}&message={str(e)[:100]}') + + +class MicrosoftOAuthInitiateView(APIView): + """ + Initiate Microsoft OAuth flow for email access. + + POST /api/oauth/microsoft/initiate/ + Body: { "purpose": "email" } + + Returns authorization URL to redirect user to. + """ + permission_classes = [IsPlatformAdmin] + + def post(self, request): + purpose = request.data.get('purpose', 'email') + + service = MicrosoftOAuthService() + if not service.is_configured(): + return Response({ + 'success': False, + 'error': 'Microsoft OAuth is not configured. Please add MICROSOFT_OAUTH_CLIENT_ID and MICROSOFT_OAUTH_CLIENT_SECRET to settings.', + }, status=status.HTTP_400_BAD_REQUEST) + + # Generate state token for CSRF protection + state = secrets.token_urlsafe(32) + + # Store state in session + request.session['oauth_state'] = state + request.session['oauth_purpose'] = purpose + request.session['oauth_provider'] = 'microsoft' + + redirect_uri = get_oauth_redirect_uri(request, 'microsoft') + + try: + auth_url = service.get_authorization_url( + redirect_uri=redirect_uri, + state=state, + ) + + return Response({ + 'success': True, + 'authorization_url': auth_url, + }) + + except Exception as e: + logger.error(f"Failed to generate Microsoft OAuth URL: {e}") + return Response({ + 'success': False, + 'error': str(e), + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class MicrosoftOAuthCallbackView(APIView): + """ + Handle Microsoft OAuth callback. + + GET /api/oauth/microsoft/callback/?code=...&state=... + + Exchanges code for tokens and saves credential. + Redirects to frontend with success/error status. + """ + permission_classes = [] # Public endpoint (callback from Microsoft) + + def get(self, request): + code = request.GET.get('code') + state = request.GET.get('state') + error = request.GET.get('error') + error_description = request.GET.get('error_description', '') + + # Frontend URL to redirect to after OAuth + frontend_base = settings.FRONTEND_URL if hasattr(settings, 'FRONTEND_URL') else 'http://platform.lvh.me:5173' + success_url = f'{frontend_base}/platform/settings?oauth=success&provider=microsoft' + error_url = f'{frontend_base}/platform/settings?oauth=error&provider=microsoft' + + if error: + logger.warning(f"Microsoft OAuth error: {error} - {error_description}") + return redirect(f'{error_url}&message={error}') + + # Verify state + expected_state = request.session.get('oauth_state') + if not state or state != expected_state: + logger.warning("Microsoft OAuth state mismatch") + return redirect(f'{error_url}&message=invalid_state') + + if not code: + return redirect(f'{error_url}&message=no_code') + + service = MicrosoftOAuthService() + redirect_uri = get_oauth_redirect_uri(request, 'microsoft') + + try: + # Exchange code for tokens + tokens = service.exchange_code_for_tokens( + code=code, + redirect_uri=redirect_uri, + ) + + email = tokens.get('email', '') + if not email: + return redirect(f'{error_url}&message=no_email') + + purpose = request.session.get('oauth_purpose', 'email') + + # Save or update credential + credential, created = OAuthCredential.objects.update_or_create( + tenant=None, # Platform-level credential + email=email, + purpose=purpose, + defaults={ + 'provider': 'microsoft', + 'access_token': tokens['access_token'], + 'refresh_token': tokens.get('refresh_token', ''), + 'scopes': tokens.get('scopes', []), + 'is_valid': True, + 'authorized_by': request.user if request.user.is_authenticated else None, + } + ) + + # Set token expiry properly + if tokens.get('expires_in'): + from django.utils import timezone + from datetime import timedelta + credential.token_expiry = timezone.now() + timedelta(seconds=tokens['expires_in']) + credential.save(update_fields=['token_expiry']) + + # Clear session + request.session.pop('oauth_state', None) + request.session.pop('oauth_purpose', None) + request.session.pop('oauth_provider', None) + + logger.info(f"Successfully stored Microsoft OAuth credential for {email}") + return redirect(f'{success_url}&email={email}') + + except Exception as e: + logger.error(f"Microsoft OAuth callback error: {e}") + return redirect(f'{error_url}&message={str(e)[:100]}') + + +class OAuthCredentialListView(APIView): + """ + List OAuth credentials. + + GET /api/oauth/credentials/ + + Returns list of stored OAuth credentials (tokens are masked). + """ + permission_classes = [IsPlatformAdmin] + + def get(self, request): + credentials = OAuthCredential.objects.filter( + tenant=None, # Platform-level only + purpose='email', + ).order_by('-created_at') + + return Response([ + { + 'id': cred.id, + 'provider': cred.provider, + 'email': cred.email, + 'purpose': cred.purpose, + 'is_valid': cred.is_valid, + 'is_expired': cred.is_expired(), + 'last_used_at': cred.last_used_at, + 'last_error': cred.last_error, + 'created_at': cred.created_at, + } + for cred in credentials + ]) + + +class OAuthCredentialDeleteView(APIView): + """ + Delete/revoke an OAuth credential. + + DELETE /api/oauth/credentials/{id}/ + + Removes the stored credential. + """ + permission_classes = [IsPlatformAdmin] + + def delete(self, request, credential_id): + try: + credential = OAuthCredential.objects.get( + id=credential_id, + tenant=None, # Platform-level only + ) + email = credential.email + credential.delete() + + logger.info(f"Deleted OAuth credential for {email}") + return Response({ + 'success': True, + 'message': f'Credential for {email} deleted', + }) + + except OAuthCredential.DoesNotExist: + return Response({ + 'success': False, + 'error': 'Credential not found', + }, status=status.HTTP_404_NOT_FOUND) diff --git a/smoothschedule/pyproject.toml b/smoothschedule/pyproject.toml index ff7f9dc..fdae5b5 100644 --- a/smoothschedule/pyproject.toml +++ b/smoothschedule/pyproject.toml @@ -198,4 +198,8 @@ dependencies = [ "dj-stripe>=2.9.0", "django-csp==3.8.0", "twilio>=9.0.0", + "dnspython>=2.6.0", + "google-auth>=2.0.0", + "google-auth-oauthlib>=1.0.0", + "msal>=1.24.0", ] diff --git a/smoothschedule/tickets/migrations/0009_add_oauth_credential_to_email_settings.py b/smoothschedule/tickets/migrations/0009_add_oauth_credential_to_email_settings.py new file mode 100644 index 0000000..342d467 --- /dev/null +++ b/smoothschedule/tickets/migrations/0009_add_oauth_credential_to_email_settings.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.8 on 2025-11-30 00:49 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_add_oauth_credential_model'), + ('tickets', '0008_add_smtp_settings'), + ] + + operations = [ + migrations.AddField( + model_name='ticketemailsettings', + name='oauth_credential', + field=models.ForeignKey(blank=True, help_text='OAuth credential for XOAUTH2 authentication (Gmail/Microsoft)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_email_settings', to='core.oauthcredential'), + ), + ] diff --git a/smoothschedule/tickets/models.py b/smoothschedule/tickets/models.py index 2d56e5d..019325e 100644 --- a/smoothschedule/tickets/models.py +++ b/smoothschedule/tickets/models.py @@ -497,6 +497,16 @@ class TicketEmailSettings(models.Model): help_text="Total number of emails processed" ) + # OAuth credential for XOAUTH2 authentication (alternative to password) + oauth_credential = models.ForeignKey( + 'core.OAuthCredential', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='ticket_email_settings', + help_text="OAuth credential for XOAUTH2 authentication (Gmail/Microsoft)" + ) + created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -523,22 +533,26 @@ class TicketEmailSettings(models.Model): instance, _ = cls.objects.get_or_create(pk=1) return instance + def uses_oauth(self): + """Check if using OAuth for authentication.""" + return self.oauth_credential is not None and self.oauth_credential.is_valid + def is_imap_configured(self): """Check if IMAP (inbound) settings are properly configured.""" - return bool( - self.imap_host and - self.imap_username and - self.imap_password - ) + has_host = bool(self.imap_host) + has_username = bool(self.imap_username) + # Either password or OAuth credential is required + has_auth = bool(self.imap_password) or self.uses_oauth() + return has_host and has_username and has_auth def is_smtp_configured(self): """Check if SMTP (outbound) settings are properly configured.""" - return bool( - self.smtp_host and - self.smtp_username and - self.smtp_password and - self.smtp_from_email - ) + has_host = bool(self.smtp_host) + has_username = bool(self.smtp_username) + has_from = bool(self.smtp_from_email) + # Either password or OAuth credential is required + has_auth = bool(self.smtp_password) or self.uses_oauth() + return has_host and has_username and has_from and has_auth def is_configured(self): """Check if email settings are properly configured (both IMAP and SMTP).""" diff --git a/smoothschedule/tickets/urls.py b/smoothschedule/tickets/urls.py index 9a3cc81..e3b7315 100644 --- a/smoothschedule/tickets/urls.py +++ b/smoothschedule/tickets/urls.py @@ -5,7 +5,7 @@ from .views import ( TicketTemplateViewSet, CannedResponseViewSet, TicketEmailSettingsView, TicketEmailTestConnectionView, TicketEmailTestSmtpView, TicketEmailFetchNowView, - IncomingTicketEmailViewSet + IncomingTicketEmailViewSet, EmailProviderDetectView ) app_name = 'tickets' @@ -35,6 +35,7 @@ incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incom urlpatterns = [ # Email settings endpoints (platform admin only) - must be BEFORE router.urls path('email-settings/', TicketEmailSettingsView.as_view(), name='email-settings'), + path('email-settings/detect/', EmailProviderDetectView.as_view(), name='email-detect'), path('email-settings/test-imap/', TicketEmailTestConnectionView.as_view(), name='email-test-imap'), path('email-settings/test-smtp/', TicketEmailTestSmtpView.as_view(), name='email-test-smtp'), path('email-settings/fetch-now/', TicketEmailFetchNowView.as_view(), name='email-fetch-now'), diff --git a/smoothschedule/tickets/views.py b/smoothschedule/tickets/views.py index bebbcf0..5d7f780 100644 --- a/smoothschedule/tickets/views.py +++ b/smoothschedule/tickets/views.py @@ -1,3 +1,4 @@ +import dns.resolver from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response @@ -645,4 +646,297 @@ class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet): return Response({ 'success': False, 'message': f'Error reprocessing: {str(e)}', - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# Email provider database for auto-detection +EMAIL_PROVIDERS = { + # Gmail + 'gmail.com': { + 'provider': 'google', + 'display_name': 'Gmail', + 'imap_host': 'imap.gmail.com', + 'imap_port': 993, + 'smtp_host': 'smtp.gmail.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + 'googlemail.com': { + 'provider': 'google', + 'display_name': 'Gmail', + 'imap_host': 'imap.gmail.com', + 'imap_port': 993, + 'smtp_host': 'smtp.gmail.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + # Microsoft + 'outlook.com': { + 'provider': 'microsoft', + 'display_name': 'Outlook.com', + 'imap_host': 'outlook.office365.com', + 'imap_port': 993, + 'smtp_host': 'smtp.office365.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + 'hotmail.com': { + 'provider': 'microsoft', + 'display_name': 'Hotmail', + 'imap_host': 'outlook.office365.com', + 'imap_port': 993, + 'smtp_host': 'smtp.office365.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + 'live.com': { + 'provider': 'microsoft', + 'display_name': 'Live', + 'imap_host': 'outlook.office365.com', + 'imap_port': 993, + 'smtp_host': 'smtp.office365.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + 'msn.com': { + 'provider': 'microsoft', + 'display_name': 'MSN', + 'imap_host': 'outlook.office365.com', + 'imap_port': 993, + 'smtp_host': 'smtp.office365.com', + 'smtp_port': 587, + 'oauth_supported': True, + }, + # Yahoo + 'yahoo.com': { + 'provider': 'yahoo', + 'display_name': 'Yahoo Mail', + 'imap_host': 'imap.mail.yahoo.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.yahoo.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + 'yahoo.co.uk': { + 'provider': 'yahoo', + 'display_name': 'Yahoo Mail UK', + 'imap_host': 'imap.mail.yahoo.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.yahoo.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + # Apple iCloud + 'icloud.com': { + 'provider': 'apple', + 'display_name': 'iCloud Mail', + 'imap_host': 'imap.mail.me.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.me.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + 'me.com': { + 'provider': 'apple', + 'display_name': 'iCloud Mail', + 'imap_host': 'imap.mail.me.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.me.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + 'mac.com': { + 'provider': 'apple', + 'display_name': 'iCloud Mail', + 'imap_host': 'imap.mail.me.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.me.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + # AOL + 'aol.com': { + 'provider': 'aol', + 'display_name': 'AOL Mail', + 'imap_host': 'imap.aol.com', + 'imap_port': 993, + 'smtp_host': 'smtp.aol.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + # Zoho + 'zoho.com': { + 'provider': 'zoho', + 'display_name': 'Zoho Mail', + 'imap_host': 'imap.zoho.com', + 'imap_port': 993, + 'smtp_host': 'smtp.zoho.com', + 'smtp_port': 587, + 'oauth_supported': False, + }, + # ProtonMail (Bridge required) + 'protonmail.com': { + 'provider': 'protonmail', + 'display_name': 'ProtonMail', + 'imap_host': '127.0.0.1', + 'imap_port': 1143, + 'smtp_host': '127.0.0.1', + 'smtp_port': 1025, + 'oauth_supported': False, + 'notes': 'Requires ProtonMail Bridge application', + }, + 'proton.me': { + 'provider': 'protonmail', + 'display_name': 'Proton Mail', + 'imap_host': '127.0.0.1', + 'imap_port': 1143, + 'smtp_host': '127.0.0.1', + 'smtp_port': 1025, + 'oauth_supported': False, + 'notes': 'Requires ProtonMail Bridge application', + }, +} + + +def detect_provider_from_mx(domain: str) -> dict | None: + """ + Detect email provider from MX records for custom domains. + Used to identify Google Workspace or Microsoft 365 hosted domains. + """ + try: + mx_records = dns.resolver.resolve(domain, 'MX') + mx_hosts = [str(record.exchange).lower() for record in mx_records] + + # Check for Google Workspace + for mx in mx_hosts: + if 'google' in mx or 'googlemail' in mx: + return { + 'provider': 'google', + 'display_name': 'Google Workspace', + 'imap_host': 'imap.gmail.com', + 'imap_port': 993, + 'smtp_host': 'smtp.gmail.com', + 'smtp_port': 587, + 'oauth_supported': True, + 'detected_via': 'mx_record', + } + + # Check for Microsoft 365 + for mx in mx_hosts: + if 'outlook' in mx or 'microsoft' in mx or 'office365' in mx: + return { + 'provider': 'microsoft', + 'display_name': 'Microsoft 365', + 'imap_host': 'outlook.office365.com', + 'imap_port': 993, + 'smtp_host': 'smtp.office365.com', + 'smtp_port': 587, + 'oauth_supported': True, + 'detected_via': 'mx_record', + } + + # Check for Zoho + for mx in mx_hosts: + if 'zoho' in mx: + return { + 'provider': 'zoho', + 'display_name': 'Zoho Mail', + 'imap_host': 'imap.zoho.com', + 'imap_port': 993, + 'smtp_host': 'smtp.zoho.com', + 'smtp_port': 587, + 'oauth_supported': False, + 'detected_via': 'mx_record', + } + + # Check for Yahoo/AT&T (they use Yahoo infrastructure) + for mx in mx_hosts: + if 'yahoodns' in mx or 'yahoo' in mx: + return { + 'provider': 'yahoo', + 'display_name': 'Yahoo Mail', + 'imap_host': 'imap.mail.yahoo.com', + 'imap_port': 993, + 'smtp_host': 'smtp.mail.yahoo.com', + 'smtp_port': 587, + 'oauth_supported': False, + 'detected_via': 'mx_record', + } + + return None + + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.resolver.NoNameservers): + return None + except Exception: + return None + + +class EmailProviderDetectView(APIView): + """ + API endpoint to detect email provider settings from an email address. + Used by the email configuration wizard for auto-detection. + + POST /api/tickets/email-settings/detect/ + Body: { "email": "support@company.com" } + + Returns detected provider info and suggested IMAP/SMTP settings. + """ + permission_classes = [IsPlatformAdmin] + + def post(self, request): + email = request.data.get('email', '').strip().lower() + + if not email or '@' not in email: + return Response({ + 'success': False, + 'error': 'Valid email address required', + }, status=status.HTTP_400_BAD_REQUEST) + + # Extract domain from email + try: + domain = email.split('@')[1] + except IndexError: + return Response({ + 'success': False, + 'error': 'Invalid email format', + }, status=status.HTTP_400_BAD_REQUEST) + + # First check known providers + provider_info = EMAIL_PROVIDERS.get(domain) + + if provider_info: + return Response({ + 'success': True, + 'email': email, + 'domain': domain, + 'detected': True, + 'detected_via': 'domain_lookup', + **provider_info, + }) + + # For custom domains, try MX record lookup + mx_provider = detect_provider_from_mx(domain) + + if mx_provider: + return Response({ + 'success': True, + 'email': email, + 'domain': domain, + 'detected': True, + **mx_provider, + }) + + # Unknown provider - return generic settings hint + return Response({ + 'success': True, + 'email': email, + 'domain': domain, + 'detected': False, + 'provider': 'unknown', + 'display_name': 'Custom/Unknown', + 'oauth_supported': False, + 'message': 'Could not auto-detect provider. Please enter IMAP/SMTP settings manually.', + # Provide common default port suggestions + 'suggested_imap_port': 993, + 'suggested_smtp_port': 587, + }) \ No newline at end of file diff --git a/smoothschedule/uv.lock b/smoothschedule/uv.lock index 6264762..36dc7d4 100644 --- a/smoothschedule/uv.lock +++ b/smoothschedule/uv.lock @@ -209,6 +209,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" }, ] +[[package]] +name = "cachetools" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, +] + [[package]] name = "celery" version = "5.5.3" @@ -837,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -983,6 +1001,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-auth" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955, upload-time = "2025-04-22T16:40:29.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" }, +] + [[package]] name = "greenlet" version = "3.2.4" @@ -1283,6 +1328,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] +[[package]] +name = "msal" +version = "1.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, +] + [[package]] name = "msgpack" version = "1.1.2" @@ -1383,6 +1442,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1609,6 +1677,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "2.23" @@ -1648,6 +1737,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.1" @@ -1834,6 +1928,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "roman-numerals-py" version = "3.1.0" @@ -1880,6 +1987,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/3a/12dc43f13594a54ea0c9d7e9d43002116557330e3ad45bc56097ddf266e2/rpds_py-0.29.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f49196aec7c4b406495f60e6f947ad71f317a765f956d74bbd83996b9edc0352", size = 225248, upload-time = "2025-11-16T14:49:24.841Z" }, ] +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + [[package]] name = "ruff" version = "0.14.6" @@ -1965,10 +2084,14 @@ dependencies = [ { name = "django-storages", extra = ["s3"] }, { name = "django-tenants" }, { name = "djangorestframework" }, + { name = "dnspython" }, { name = "drf-spectacular" }, { name = "flower" }, + { name = "google-auth" }, + { name = "google-auth-oauthlib" }, { name = "gunicorn" }, { name = "hiredis" }, + { name = "msal" }, { name = "pillow" }, { name = "psycopg", extra = ["c"] }, { name = "python-slugify" }, @@ -2026,10 +2149,14 @@ requires-dist = [ { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-tenants", specifier = ">=3.6" }, { name = "djangorestframework", specifier = "==3.16.1" }, + { name = "dnspython", specifier = ">=2.6.0" }, { name = "drf-spectacular", specifier = "==0.29.0" }, { name = "flower", specifier = "==2.0.1" }, + { name = "google-auth", specifier = ">=2.0.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.0.0" }, { name = "gunicorn", specifier = "==23.0.0" }, { name = "hiredis", specifier = "==3.3.0" }, + { name = "msal", specifier = ">=1.24.0" }, { name = "pillow", specifier = "==12.0.0" }, { name = "psycopg", extras = ["c"], specifier = "==3.2.13" }, { name = "python-slugify", specifier = "==8.0.4" },