feat: Add OAuth email integration and production deployment config

- Add OAuthCredential model for storing Google/Microsoft OAuth tokens
- Add email provider auto-detection endpoint (Gmail, Outlook, Yahoo, etc.)
- Add EmailConfigWizard frontend component with step-by-step setup
- Add OAuth flow endpoints for Google and Microsoft XOAUTH2
- Update production settings to make AWS, Sentry, Mailgun optional
- Update Traefik config for wildcard subdomain routing
- Add logo resize utility script

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-29 21:26:17 -05:00
parent cfc1b36ada
commit 7b0cf62019
22 changed files with 3075 additions and 96 deletions

View File

@@ -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

View File

@@ -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<EmailProviderDetectResult> => {
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<OAuthStatusResult> => {
const response = await apiClient.get('/api/oauth/status/');
return response.data;
};
/**
* Initiate Google OAuth flow
*/
export const initiateGoogleOAuth = async (purpose: string = 'email'): Promise<OAuthInitiateResult> => {
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<OAuthInitiateResult> => {
const response = await apiClient.post('/api/oauth/microsoft/initiate/', { purpose });
return response.data;
};
/**
* List OAuth credentials
*/
export const getOAuthCredentials = async (): Promise<OAuthCredential[]> => {
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;
};

File diff suppressed because it is too large Load Diff

View File

@@ -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<OAuthStatusResult>({
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<OAuthCredential[]>({
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,
};

View File

@@ -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 (
<div className="space-y-6">
<EmailConfigWizard
onComplete={() => {
setShowWizard(false);
refetch();
}}
onCancel={() => setShowWizard(false)}
initialEmail={emailSettings?.imap_username || ''}
/>
</div>
);
}
return (
<div className="space-y-6">
{/* Email Processing Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Mail className="w-5 h-5" />
{t('platform.settings.emailProcessing', 'Support Email Processing')}
</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Mail className="w-5 h-5" />
{t('platform.settings.emailProcessing', 'Support Email Processing')}
</h2>
<button
onClick={() => setShowWizard(true)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
<Wand2 className="w-4 h-4" />
{t('platform.settings.setupWizard', 'Setup Wizard')}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">

65
resize_logo.py Normal file
View File

@@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -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:

View File

@@ -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")

View File

@@ -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
# -------------------------------------------------------------------------------

View File

@@ -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"),

View File

@@ -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')},
},
),
]

View File

@@ -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.

View File

@@ -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()

View File

@@ -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/<int:credential_id>/', OAuthCredentialDeleteView.as_view(), name='credential-delete'),
]

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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'),
),
]

View File

@@ -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)."""

View File

@@ -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'),

View File

@@ -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)
}, 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,
})

127
smoothschedule/uv.lock generated
View File

@@ -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" },