feat: Multi-email ticketing system with platform email addresses
- Add PlatformEmailAddress model for managing platform-level email addresses - Add TicketEmailAddress model for tenant-level email addresses - Create MailServerService for IMAP integration with mail.talova.net - Implement PlatformEmailReceiver for processing incoming platform emails - Add email autoconfiguration for Mozilla, Microsoft, and Apple clients - Add configurable email polling interval in platform settings - Add "Check Emails" button on support page for manual refresh - Add ticket counts to status tabs on support page - Add platform email addresses management page - Add Privacy Policy and Terms of Service pages - Add robots.txt for SEO - Restrict email addresses to smoothschedule.com domain only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
5
frontend/public/robots.txt
Normal file
5
frontend/public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# robots.txt - SmoothSchedule
|
||||||
|
# Deny all robots while in development
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
@@ -29,6 +29,8 @@ const PricingPage = React.lazy(() => import('./pages/marketing/PricingPage'));
|
|||||||
const AboutPage = React.lazy(() => import('./pages/marketing/AboutPage'));
|
const AboutPage = React.lazy(() => import('./pages/marketing/AboutPage'));
|
||||||
const ContactPage = React.lazy(() => import('./pages/marketing/ContactPage'));
|
const ContactPage = React.lazy(() => import('./pages/marketing/ContactPage'));
|
||||||
const SignupPage = React.lazy(() => import('./pages/marketing/SignupPage'));
|
const SignupPage = React.lazy(() => import('./pages/marketing/SignupPage'));
|
||||||
|
const PrivacyPolicyPage = React.lazy(() => import('./pages/marketing/PrivacyPolicyPage'));
|
||||||
|
const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfServicePage'));
|
||||||
|
|
||||||
// Import pages
|
// Import pages
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||||
@@ -50,6 +52,7 @@ const Upgrade = React.lazy(() => import('./pages/Upgrade'));
|
|||||||
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
|
const PlatformDashboard = React.lazy(() => import('./pages/platform/PlatformDashboard'));
|
||||||
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
|
const PlatformBusinesses = React.lazy(() => import('./pages/platform/PlatformBusinesses'));
|
||||||
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
|
const PlatformSupportPage = React.lazy(() => import('./pages/platform/PlatformSupport'));
|
||||||
|
const PlatformEmailAddresses = React.lazy(() => import('./pages/platform/PlatformEmailAddresses'));
|
||||||
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
|
const PlatformUsers = React.lazy(() => import('./pages/platform/PlatformUsers'));
|
||||||
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'));
|
||||||
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
|
||||||
@@ -63,6 +66,7 @@ const HelpGuide = React.lazy(() => import('./pages/HelpGuide')); // Import Platf
|
|||||||
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
const HelpTicketing = React.lazy(() => import('./pages/HelpTicketing')); // Import Help page for ticketing
|
||||||
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
|
const HelpApiDocs = React.lazy(() => import('./pages/HelpApiDocs')); // Import API documentation page
|
||||||
const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page
|
const HelpPluginDocs = React.lazy(() => import('./pages/HelpPluginDocs')); // Import Plugin documentation page
|
||||||
|
const HelpEmailSettings = React.lazy(() => import('./pages/HelpEmailSettings')); // Import Email settings help page
|
||||||
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||||
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
const PluginMarketplace = React.lazy(() => import('./pages/PluginMarketplace')); // Import Plugin Marketplace page
|
||||||
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
const MyPlugins = React.lazy(() => import('./pages/MyPlugins')); // Import My Plugins page
|
||||||
@@ -228,6 +232,8 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
|
<Route path="/terms" element={<TermsOfServicePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
@@ -269,6 +275,8 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
|
<Route path="/terms" element={<TermsOfServicePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
|
||||||
@@ -376,10 +384,12 @@ const AppContent: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
<Route path="/platform/support" element={<PlatformSupportPage />} />
|
||||||
|
<Route path="/platform/email-addresses" element={<PlatformEmailAddresses />} />
|
||||||
<Route path="/help/guide" element={<HelpGuide />} />
|
<Route path="/help/guide" element={<HelpGuide />} />
|
||||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||||
|
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||||
{user.role === 'superuser' && (
|
{user.role === 'superuser' && (
|
||||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||||
)}
|
)}
|
||||||
@@ -567,6 +577,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
<Route path="/help/ticketing" element={<HelpTicketing />} />
|
||||||
<Route path="/help/api" element={<HelpApiDocs />} />
|
<Route path="/help/api" element={<HelpApiDocs />} />
|
||||||
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
<Route path="/help/plugins" element={<HelpPluginDocs />} />
|
||||||
|
<Route path="/help/email" element={<HelpEmailSettings />} />
|
||||||
<Route
|
<Route
|
||||||
path="/plugins/marketplace"
|
path="/plugins/marketplace"
|
||||||
element={
|
element={
|
||||||
|
|||||||
250
frontend/src/api/platformEmailAddresses.ts
Normal file
250
frontend/src/api/platformEmailAddresses.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* API client for Platform Email Addresses
|
||||||
|
* These are email addresses managed directly on the mail.talova.net server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export interface PlatformEmailAddress {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
sender_name: string;
|
||||||
|
effective_sender_name: string;
|
||||||
|
local_part: string;
|
||||||
|
domain: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
mail_server_synced: boolean;
|
||||||
|
last_sync_error?: string;
|
||||||
|
last_synced_at?: string;
|
||||||
|
last_check_at?: string;
|
||||||
|
emails_processed_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
imap_settings?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
use_ssl: boolean;
|
||||||
|
username: string;
|
||||||
|
folder: string;
|
||||||
|
};
|
||||||
|
smtp_settings?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
use_tls: boolean;
|
||||||
|
use_ssl: boolean;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignedUser {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
full_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssignableUser extends AssignedUser {
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformEmailAddressListItem {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
sender_name: string;
|
||||||
|
effective_sender_name: string;
|
||||||
|
local_part: string;
|
||||||
|
domain: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
assigned_user?: AssignedUser | null;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
mail_server_synced: boolean;
|
||||||
|
last_check_at?: string;
|
||||||
|
emails_processed_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformEmailAddressCreate {
|
||||||
|
display_name: string;
|
||||||
|
sender_name?: string;
|
||||||
|
assigned_user_id?: number | null;
|
||||||
|
local_part: string;
|
||||||
|
domain: string;
|
||||||
|
color: string;
|
||||||
|
password: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformEmailAddressUpdate {
|
||||||
|
display_name?: string;
|
||||||
|
sender_name?: string;
|
||||||
|
assigned_user_id?: number | null;
|
||||||
|
color?: string;
|
||||||
|
password?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailDomain {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestConnectionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
mail_server_synced?: boolean;
|
||||||
|
last_synced_at?: string;
|
||||||
|
last_sync_error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailServerAccountsResponse {
|
||||||
|
success: boolean;
|
||||||
|
accounts: { email: string; raw_line: string }[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportFromMailServerResponse {
|
||||||
|
success: boolean;
|
||||||
|
imported: { id: number; email: string; display_name: string }[];
|
||||||
|
imported_count: number;
|
||||||
|
skipped: { email: string; reason: string }[];
|
||||||
|
skipped_count: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platform email addresses
|
||||||
|
*/
|
||||||
|
export const getPlatformEmailAddresses = async (): Promise<PlatformEmailAddressListItem[]> => {
|
||||||
|
const response = await apiClient.get('/platform/email-addresses/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific platform email address by ID
|
||||||
|
*/
|
||||||
|
export const getPlatformEmailAddress = async (id: number): Promise<PlatformEmailAddress> => {
|
||||||
|
const response = await apiClient.get(`/platform/email-addresses/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new platform email address
|
||||||
|
*/
|
||||||
|
export const createPlatformEmailAddress = async (
|
||||||
|
data: PlatformEmailAddressCreate
|
||||||
|
): Promise<PlatformEmailAddress> => {
|
||||||
|
const response = await apiClient.post('/platform/email-addresses/', data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing platform email address
|
||||||
|
*/
|
||||||
|
export const updatePlatformEmailAddress = async (
|
||||||
|
id: number,
|
||||||
|
data: PlatformEmailAddressUpdate
|
||||||
|
): Promise<PlatformEmailAddress> => {
|
||||||
|
const response = await apiClient.patch(`/platform/email-addresses/${id}/`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a platform email address (also removes from mail server)
|
||||||
|
*/
|
||||||
|
export const deletePlatformEmailAddress = async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/platform/email-addresses/${id}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove email address from database only (keeps mail server account)
|
||||||
|
*/
|
||||||
|
export const removeLocalPlatformEmailAddress = async (id: number): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/platform/email-addresses/${id}/remove_local/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync email address to mail server
|
||||||
|
*/
|
||||||
|
export const syncPlatformEmailAddress = async (id: number): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post(`/platform/email-addresses/${id}/sync/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test IMAP connection for a platform email address
|
||||||
|
*/
|
||||||
|
export const testImapConnection = async (id: number): Promise<TestConnectionResponse> => {
|
||||||
|
const response = await apiClient.post(`/platform/email-addresses/${id}/test_imap/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SMTP connection for a platform email address
|
||||||
|
*/
|
||||||
|
export const testSmtpConnection = async (id: number): Promise<TestConnectionResponse> => {
|
||||||
|
const response = await apiClient.post(`/platform/email-addresses/${id}/test_smtp/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a platform email address as the default
|
||||||
|
*/
|
||||||
|
export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/platform/email-addresses/${id}/set_as_default/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SSH connection to the mail server
|
||||||
|
*/
|
||||||
|
export const testMailServerConnection = async (): Promise<TestConnectionResponse> => {
|
||||||
|
const response = await apiClient.post('/platform/email-addresses/test_mail_server/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all email accounts from the mail server
|
||||||
|
*/
|
||||||
|
export const getMailServerAccounts = async (): Promise<MailServerAccountsResponse> => {
|
||||||
|
const response = await apiClient.get('/platform/email-addresses/mail_server_accounts/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available email domains
|
||||||
|
*/
|
||||||
|
export const getAvailableDomains = async (): Promise<{ domains: EmailDomain[] }> => {
|
||||||
|
const response = await apiClient.get('/platform/email-addresses/available_domains/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assignable users (platform users who can be assigned to email addresses)
|
||||||
|
*/
|
||||||
|
export const getAssignableUsers = async (): Promise<{ users: AssignableUser[] }> => {
|
||||||
|
const response = await apiClient.get('/platform/email-addresses/assignable_users/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import email addresses from the mail server
|
||||||
|
*/
|
||||||
|
export const importFromMailServer = async (): Promise<ImportFromMailServerResponse> => {
|
||||||
|
const response = await apiClient.post('/platform/email-addresses/import_from_mail_server/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
157
frontend/src/api/ticketEmailAddresses.ts
Normal file
157
frontend/src/api/ticketEmailAddresses.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* API client for Ticket Email Addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export interface TicketEmailAddress {
|
||||||
|
id: number;
|
||||||
|
tenant: number;
|
||||||
|
tenant_name: string;
|
||||||
|
display_name: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
imap_host: string;
|
||||||
|
imap_port: number;
|
||||||
|
imap_use_ssl: boolean;
|
||||||
|
imap_username: string;
|
||||||
|
imap_password?: string;
|
||||||
|
imap_folder: string;
|
||||||
|
smtp_host: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_use_tls: boolean;
|
||||||
|
smtp_use_ssl: boolean;
|
||||||
|
smtp_username: string;
|
||||||
|
smtp_password?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
last_check_at?: string;
|
||||||
|
last_error?: string;
|
||||||
|
emails_processed_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_imap_configured: boolean;
|
||||||
|
is_smtp_configured: boolean;
|
||||||
|
is_fully_configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketEmailAddressListItem {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
last_check_at?: string;
|
||||||
|
emails_processed_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketEmailAddressCreate {
|
||||||
|
display_name: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
imap_host: string;
|
||||||
|
imap_port: number;
|
||||||
|
imap_use_ssl: boolean;
|
||||||
|
imap_username: string;
|
||||||
|
imap_password: string;
|
||||||
|
imap_folder: string;
|
||||||
|
smtp_host: string;
|
||||||
|
smtp_port: number;
|
||||||
|
smtp_use_tls: boolean;
|
||||||
|
smtp_use_ssl: boolean;
|
||||||
|
smtp_username: string;
|
||||||
|
smtp_password: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestConnectionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchEmailsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
processed?: number;
|
||||||
|
errors?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ticket email addresses for the current business
|
||||||
|
*/
|
||||||
|
export const getTicketEmailAddresses = async (): Promise<TicketEmailAddressListItem[]> => {
|
||||||
|
const response = await apiClient.get('/tickets/email-addresses/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific ticket email address by ID
|
||||||
|
*/
|
||||||
|
export const getTicketEmailAddress = async (id: number): Promise<TicketEmailAddress> => {
|
||||||
|
const response = await apiClient.get(`/tickets/email-addresses/${id}/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new ticket email address
|
||||||
|
*/
|
||||||
|
export const createTicketEmailAddress = async (
|
||||||
|
data: TicketEmailAddressCreate
|
||||||
|
): Promise<TicketEmailAddress> => {
|
||||||
|
const response = await apiClient.post('/tickets/email-addresses/', data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing ticket email address
|
||||||
|
*/
|
||||||
|
export const updateTicketEmailAddress = async (
|
||||||
|
id: number,
|
||||||
|
data: Partial<TicketEmailAddressCreate>
|
||||||
|
): Promise<TicketEmailAddress> => {
|
||||||
|
const response = await apiClient.patch(`/tickets/email-addresses/${id}/`, data);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a ticket email address
|
||||||
|
*/
|
||||||
|
export const deleteTicketEmailAddress = async (id: number): Promise<void> => {
|
||||||
|
await apiClient.delete(`/tickets/email-addresses/${id}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test IMAP connection for an email address
|
||||||
|
*/
|
||||||
|
export const testImapConnection = async (id: number): Promise<TestConnectionResponse> => {
|
||||||
|
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_imap/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test SMTP connection for an email address
|
||||||
|
*/
|
||||||
|
export const testSmtpConnection = async (id: number): Promise<TestConnectionResponse> => {
|
||||||
|
const response = await apiClient.post(`/tickets/email-addresses/${id}/test_smtp/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually fetch emails for an email address
|
||||||
|
*/
|
||||||
|
export const fetchEmailsNow = async (id: number): Promise<FetchEmailsResponse> => {
|
||||||
|
const response = await apiClient.post(`/tickets/email-addresses/${id}/fetch_now/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an email address as the default for the business
|
||||||
|
*/
|
||||||
|
export const setAsDefault = async (id: number): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(`/tickets/email-addresses/${id}/set_as_default/`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
@@ -66,3 +66,23 @@ export const getCannedResponses = async (): Promise<CannedResponse[]> => {
|
|||||||
const response = await apiClient.get('/tickets/canned-responses/');
|
const response = await apiClient.get('/tickets/canned-responses/');
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Refresh emails manually
|
||||||
|
export interface RefreshEmailsResult {
|
||||||
|
success: boolean;
|
||||||
|
processed: number;
|
||||||
|
results: {
|
||||||
|
address: string | null;
|
||||||
|
display_name?: string;
|
||||||
|
processed?: number;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
last_check_at?: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const refreshTicketEmails = async (): Promise<RefreshEmailsResult> => {
|
||||||
|
const response = await apiClient.post('/tickets/refresh-emails/');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|||||||
775
frontend/src/components/PlatformEmailAddressManager.tsx
Normal file
775
frontend/src/components/PlatformEmailAddressManager.tsx
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
/**
|
||||||
|
* Platform Email Address Manager Component
|
||||||
|
* Manages email addresses hosted on mail.talova.net via SSH
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Star,
|
||||||
|
TestTube,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
AlertTriangle,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Unlink,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
usePlatformEmailAddresses,
|
||||||
|
useDeletePlatformEmailAddress,
|
||||||
|
useRemoveLocalPlatformEmailAddress,
|
||||||
|
useTestImapConnection,
|
||||||
|
useTestSmtpConnection,
|
||||||
|
useSyncPlatformEmailAddress,
|
||||||
|
useSetAsDefault,
|
||||||
|
useTestMailServerConnection,
|
||||||
|
useCreatePlatformEmailAddress,
|
||||||
|
useUpdatePlatformEmailAddress,
|
||||||
|
useAssignableUsers,
|
||||||
|
useImportFromMailServer,
|
||||||
|
PlatformEmailAddressListItem,
|
||||||
|
} from '../hooks/usePlatformEmailAddresses';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Color options for email addresses
|
||||||
|
const COLOR_OPTIONS = [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#10b981', // green
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#ef4444', // red
|
||||||
|
'#8b5cf6', // violet
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#f97316', // orange
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EmailAddressFormData {
|
||||||
|
display_name: string;
|
||||||
|
sender_name: string;
|
||||||
|
assigned_user_id: number | null;
|
||||||
|
local_part: string;
|
||||||
|
domain: string;
|
||||||
|
color: string;
|
||||||
|
password: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmModalState {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
confirmStyle: 'danger' | 'warning';
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlatformEmailAddressManager: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingAddress, setEditingAddress] = useState<PlatformEmailAddressListItem | null>(null);
|
||||||
|
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
|
||||||
|
isOpen: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmText: 'Confirm',
|
||||||
|
confirmStyle: 'danger',
|
||||||
|
onConfirm: () => {},
|
||||||
|
});
|
||||||
|
const [formData, setFormData] = useState<EmailAddressFormData>({
|
||||||
|
display_name: '',
|
||||||
|
sender_name: '',
|
||||||
|
assigned_user_id: null,
|
||||||
|
local_part: '',
|
||||||
|
domain: 'smoothschedule.com',
|
||||||
|
color: '#3b82f6',
|
||||||
|
password: '',
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const { data: emailAddresses = [], isLoading } = usePlatformEmailAddresses();
|
||||||
|
const { data: usersData } = useAssignableUsers();
|
||||||
|
const deleteAddress = useDeletePlatformEmailAddress();
|
||||||
|
const removeLocal = useRemoveLocalPlatformEmailAddress();
|
||||||
|
const testImap = useTestImapConnection();
|
||||||
|
const testSmtp = useTestSmtpConnection();
|
||||||
|
const syncAddress = useSyncPlatformEmailAddress();
|
||||||
|
const setDefault = useSetAsDefault();
|
||||||
|
const testMailServer = useTestMailServerConnection();
|
||||||
|
const createAddress = useCreatePlatformEmailAddress();
|
||||||
|
const updateAddress = useUpdatePlatformEmailAddress();
|
||||||
|
const importFromServer = useImportFromMailServer();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingAddress(null);
|
||||||
|
setFormData({
|
||||||
|
display_name: '',
|
||||||
|
sender_name: '',
|
||||||
|
assigned_user_id: null,
|
||||||
|
local_part: '',
|
||||||
|
domain: 'smoothschedule.com',
|
||||||
|
color: '#3b82f6',
|
||||||
|
password: '',
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
setFormErrors({});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (address: PlatformEmailAddressListItem) => {
|
||||||
|
setEditingAddress(address);
|
||||||
|
setFormData({
|
||||||
|
display_name: address.display_name,
|
||||||
|
sender_name: address.sender_name || '',
|
||||||
|
assigned_user_id: address.assigned_user?.id || null,
|
||||||
|
local_part: address.local_part,
|
||||||
|
domain: address.domain,
|
||||||
|
color: address.color,
|
||||||
|
password: '',
|
||||||
|
is_active: address.is_active,
|
||||||
|
is_default: address.is_default,
|
||||||
|
});
|
||||||
|
setFormErrors({});
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingAddress(null);
|
||||||
|
setFormErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.display_name.trim()) {
|
||||||
|
errors.display_name = 'Display name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingAddress && !formData.local_part.trim()) {
|
||||||
|
errors.local_part = 'Email local part is required';
|
||||||
|
} else if (!editingAddress && !/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/i.test(formData.local_part)) {
|
||||||
|
errors.local_part = 'Invalid email format';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editingAddress && !formData.password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
} else if (formData.password && formData.password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingAddress) {
|
||||||
|
// Update existing address
|
||||||
|
const updateData: any = {
|
||||||
|
display_name: formData.display_name,
|
||||||
|
sender_name: formData.sender_name,
|
||||||
|
assigned_user_id: formData.assigned_user_id,
|
||||||
|
color: formData.color,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
is_default: formData.is_default,
|
||||||
|
};
|
||||||
|
if (formData.password) {
|
||||||
|
updateData.password = formData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAddress.mutateAsync({
|
||||||
|
id: editingAddress.id,
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
toast.success('Email address updated successfully');
|
||||||
|
} else {
|
||||||
|
// Create new address
|
||||||
|
await createAddress.mutateAsync({
|
||||||
|
display_name: formData.display_name,
|
||||||
|
sender_name: formData.sender_name,
|
||||||
|
assigned_user_id: formData.assigned_user_id,
|
||||||
|
local_part: formData.local_part.toLowerCase(),
|
||||||
|
domain: formData.domain,
|
||||||
|
color: formData.color,
|
||||||
|
password: formData.password,
|
||||||
|
is_active: formData.is_active,
|
||||||
|
is_default: formData.is_default,
|
||||||
|
});
|
||||||
|
toast.success('Email address created and synced to mail server');
|
||||||
|
}
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorMessage =
|
||||||
|
error.response?.data?.mail_server ||
|
||||||
|
error.response?.data?.local_part ||
|
||||||
|
error.response?.data?.detail ||
|
||||||
|
'Failed to save email address';
|
||||||
|
toast.error(Array.isArray(errorMessage) ? errorMessage[0] : errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number, displayName: string) => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: 'Delete Email Address',
|
||||||
|
message: `Are you sure you want to delete "${displayName}"? This will permanently remove the account from both the database and the mail server. This action cannot be undone.`,
|
||||||
|
confirmText: 'Delete',
|
||||||
|
confirmStyle: 'danger',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteAddress.mutateAsync(id);
|
||||||
|
toast.success(`${displayName} deleted successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete email address');
|
||||||
|
}
|
||||||
|
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLocal = (id: number, displayName: string) => {
|
||||||
|
setConfirmModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: 'Remove from Database',
|
||||||
|
message: `Remove "${displayName}" from the database? The email account will remain active on the mail server and can be re-imported later.`,
|
||||||
|
confirmText: 'Remove',
|
||||||
|
confirmStyle: 'warning',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
const result = await removeLocal.mutateAsync(id);
|
||||||
|
toast.success(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to remove email address');
|
||||||
|
}
|
||||||
|
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConfirmModal = () => {
|
||||||
|
setConfirmModal(prev => ({ ...prev, isOpen: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestImap = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Testing IMAP connection for ${displayName}...`, { id: `imap-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await testImap.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: `imap-${id}` });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `imap-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'IMAP test failed', { id: `imap-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestSmtp = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Testing SMTP connection for ${displayName}...`, { id: `smtp-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await testSmtp.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: `smtp-${id}` });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `smtp-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'SMTP test failed', { id: `smtp-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSync = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Syncing ${displayName} to mail server...`, { id: `sync-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await syncAddress.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: `sync-${id}` });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `sync-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Sync failed', { id: `sync-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (id: number, displayName: string) => {
|
||||||
|
try {
|
||||||
|
const result = await setDefault.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to set as default');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestMailServer = async () => {
|
||||||
|
toast.loading('Testing connection to mail server...', { id: 'mail-server-test' });
|
||||||
|
try {
|
||||||
|
const result = await testMailServer.mutateAsync();
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: 'mail-server-test' });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: 'mail-server-test' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Connection test failed', { id: 'mail-server-test' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportFromServer = async () => {
|
||||||
|
toast.loading('Importing email addresses from mail server...', { id: 'import-emails' });
|
||||||
|
try {
|
||||||
|
const result = await importFromServer.mutateAsync();
|
||||||
|
if (result.success) {
|
||||||
|
if (result.imported_count > 0) {
|
||||||
|
toast.success(result.message, { id: 'import-emails' });
|
||||||
|
} else {
|
||||||
|
toast.success('No new email addresses to import', { id: 'import-emails' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Import failed', { id: 'import-emails' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Import failed', { id: 'import-emails' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Email addresses are managed directly on the mail server (mail.talova.net)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleTestMailServer}
|
||||||
|
disabled={testMailServer.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
Test Mail Server
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImportFromServer}
|
||||||
|
disabled={importFromServer.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
{importFromServer.isPending ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Import from Server
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Email Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Addresses List */}
|
||||||
|
{emailAddresses.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<Mail className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No email addresses configured
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Add your first platform email address to start receiving support tickets
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Email Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{emailAddresses.map((address) => (
|
||||||
|
<div
|
||||||
|
key={address.id}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||||
|
style={{ borderLeft: `4px solid ${address.color}` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{address.display_name}
|
||||||
|
</h3>
|
||||||
|
{address.is_default && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{address.is_active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{address.mail_server_synced ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<Server className="w-3 h-3" />
|
||||||
|
Synced
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Not Synced
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-3">{address.email_address}</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
Processed: <strong>{address.emails_processed_count}</strong> emails
|
||||||
|
</span>
|
||||||
|
{address.last_check_at && (
|
||||||
|
<span>
|
||||||
|
Last checked: {new Date(address.last_check_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!address.is_default && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefault(address.id, address.display_name)}
|
||||||
|
disabled={setDefault.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-yellow-600 dark:text-gray-400 dark:hover:text-yellow-400 transition-colors"
|
||||||
|
title="Set as default"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleSync(address.id, address.display_name)}
|
||||||
|
disabled={syncAddress.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Sync to mail server"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestImap(address.id, address.display_name)}
|
||||||
|
disabled={testImap.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400 transition-colors"
|
||||||
|
title="Test IMAP"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(address)}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveLocal(address.id, address.display_name)}
|
||||||
|
disabled={removeLocal.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-orange-600 dark:text-gray-400 dark:hover:text-orange-400 transition-colors"
|
||||||
|
title="Remove from database (keep on mail server)"
|
||||||
|
>
|
||||||
|
<Unlink className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(address.id, address.display_name)}
|
||||||
|
disabled={deleteAddress.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Delete (also removes from mail server)"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{editingAddress ? 'Edit Email Address' : 'Add Email Address'}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
||||||
|
{/* Display Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.display_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||||
|
placeholder="e.g., Support, Billing, Sales"
|
||||||
|
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{formErrors.display_name && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{formErrors.display_name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sender Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Sender Name <span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.sender_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, sender_name: e.target.value })}
|
||||||
|
placeholder="e.g., SmoothSchedule 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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Name shown in the From field of outgoing emails. If blank, uses Display Name.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assigned User */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Assigned User <span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.assigned_user_id || ''}
|
||||||
|
onChange={(e) => setFormData({
|
||||||
|
...formData,
|
||||||
|
assigned_user_id: e.target.value ? Number(e.target.value) : null
|
||||||
|
})}
|
||||||
|
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">No user assigned</option>
|
||||||
|
{usersData?.users?.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.full_name} ({user.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
If assigned, the user's name will be used as the sender name in outgoing emails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Address (only show for new addresses) */}
|
||||||
|
{!editingAddress && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.local_part}
|
||||||
|
onChange={(e) => setFormData({ ...formData, local_part: e.target.value.toLowerCase() })}
|
||||||
|
placeholder="support"
|
||||||
|
className="flex-1 min-w-0 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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<span className="flex-shrink-0 text-gray-500 dark:text-gray-400 font-medium">
|
||||||
|
@smoothschedule.com
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{formErrors.local_part && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{formErrors.local_part}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{editingAddress ? 'New Password (leave blank to keep current)' : 'Password'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder={editingAddress ? 'Leave blank to keep current' : 'Enter password'}
|
||||||
|
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{formErrors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">{formErrors.password}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Minimum 8 characters. This password will be synced to the mail server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{COLOR_OPTIONS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, color })}
|
||||||
|
className={`w-8 h-8 rounded-full ${
|
||||||
|
formData.color === color ? 'ring-2 ring-offset-2 ring-blue-500' : ''
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active & Default */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Default</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createAddress.isPending || updateAddress.isPending}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{(createAddress.isPending || updateAddress.isPending) && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{editingAddress ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{confirmModal.isOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
{confirmModal.confirmStyle === 'danger' ? (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||||
|
)}
|
||||||
|
{confirmModal.title}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeConfirmModal}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
{confirmModal.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={closeConfirmModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmModal.onConfirm}
|
||||||
|
disabled={deleteAddress.isPending || removeLocal.isPending}
|
||||||
|
className={`px-4 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${
|
||||||
|
confirmModal.confirmStyle === 'danger'
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
: 'bg-orange-600 text-white hover:bg-orange-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(deleteAddress.isPending || removeLocal.isPending) && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{confirmModal.confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformEmailAddressManager;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code } from 'lucide-react';
|
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail } from 'lucide-react';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
import SmoothScheduleLogo from './SmoothScheduleLogo';
|
||||||
|
|
||||||
@@ -63,6 +63,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
|||||||
<MessageSquare size={18} className="shrink-0" />
|
<MessageSquare size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.support')}</span>}
|
{!isCollapsed && <span>{t('nav.support')}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/platform/email-addresses" className={getNavClass('/platform/email-addresses')} title="Email Addresses">
|
||||||
|
<Mail size={18} className="shrink-0" />
|
||||||
|
{!isCollapsed && <span>Email Addresses</span>}
|
||||||
|
</Link>
|
||||||
|
|
||||||
{isSuperuser && (
|
{isSuperuser && (
|
||||||
<>
|
<>
|
||||||
@@ -84,6 +88,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
|
|||||||
<HelpCircle size={18} className="shrink-0" />
|
<HelpCircle size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.help', 'Help')}</span>}
|
{!isCollapsed && <span>{t('nav.help', 'Help')}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/help/email" className={getNavClass('/help/email')} title="Email Settings">
|
||||||
|
<Mail size={18} className="shrink-0" />
|
||||||
|
{!isCollapsed && <span>Email Settings</span>}
|
||||||
|
</Link>
|
||||||
<Link to="/help/api" className={getNavClass('/help/api')} title={t('nav.apiDocs', 'API Documentation')}>
|
<Link to="/help/api" className={getNavClass('/help/api')} title={t('nav.apiDocs', 'API Documentation')}>
|
||||||
<Code size={18} className="shrink-0" />
|
<Code size={18} className="shrink-0" />
|
||||||
{!isCollapsed && <span>{t('nav.apiDocs', 'API Docs')}</span>}
|
{!isCollapsed && <span>{t('nav.apiDocs', 'API Docs')}</span>}
|
||||||
|
|||||||
279
frontend/src/components/TicketEmailAddressManager.tsx
Normal file
279
frontend/src/components/TicketEmailAddressManager.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Ticket Email Address Manager Component
|
||||||
|
* Allows businesses to manage their ticket email addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Star,
|
||||||
|
TestTube,
|
||||||
|
RefreshCw,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useTicketEmailAddresses,
|
||||||
|
useDeleteTicketEmailAddress,
|
||||||
|
useTestImapConnection,
|
||||||
|
useTestSmtpConnection,
|
||||||
|
useFetchEmailsNow,
|
||||||
|
useSetAsDefault,
|
||||||
|
TicketEmailAddressListItem,
|
||||||
|
} from '../hooks/useTicketEmailAddresses';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import TicketEmailAddressModal from './TicketEmailAddressModal';
|
||||||
|
|
||||||
|
const TicketEmailAddressManager: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingAddress, setEditingAddress] = useState<TicketEmailAddressListItem | null>(null);
|
||||||
|
const [showPasswords, setShowPasswords] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
const { data: emailAddresses = [], isLoading } = useTicketEmailAddresses();
|
||||||
|
const deleteAddress = useDeleteTicketEmailAddress();
|
||||||
|
const testImap = useTestImapConnection();
|
||||||
|
const testSmtp = useTestSmtpConnection();
|
||||||
|
const fetchEmails = useFetchEmailsNow();
|
||||||
|
const setDefault = useSetAsDefault();
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setEditingAddress(null);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (address: TicketEmailAddressListItem) => {
|
||||||
|
setEditingAddress(address);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, displayName: string) => {
|
||||||
|
if (confirm(`Are you sure you want to delete ${displayName}?`)) {
|
||||||
|
try {
|
||||||
|
await deleteAddress.mutateAsync(id);
|
||||||
|
toast.success(`${displayName} deleted successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to delete email address');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestImap = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Testing IMAP connection for ${displayName}...`, { id: `imap-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await testImap.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: `imap-${id}` });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `imap-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'IMAP test failed', { id: `imap-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestSmtp = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Testing SMTP connection for ${displayName}...`, { id: `smtp-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await testSmtp.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: `smtp-${id}` });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `smtp-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'SMTP test failed', { id: `smtp-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchEmails = async (id: number, displayName: string) => {
|
||||||
|
toast.loading(`Fetching emails for ${displayName}...`, { id: `fetch-${id}` });
|
||||||
|
try {
|
||||||
|
const result = await fetchEmails.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(
|
||||||
|
`${result.message}. Processed: ${result.processed || 0}, Errors: ${result.errors || 0}`,
|
||||||
|
{ id: `fetch-${id}`, duration: 5000 }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: `fetch-${id}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to fetch emails', { id: `fetch-${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (id: number, displayName: string) => {
|
||||||
|
try {
|
||||||
|
const result = await setDefault.mutateAsync(id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to set as default');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Mail className="w-6 h-6" />
|
||||||
|
Email Addresses
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Manage email addresses for receiving and sending support tickets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Email Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Addresses List */}
|
||||||
|
{emailAddresses.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<Mail className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
|
No email addresses configured
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Add your first email address to start receiving tickets via email
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Email Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{emailAddresses.map((address) => (
|
||||||
|
<div
|
||||||
|
key={address.id}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6"
|
||||||
|
style={{ borderLeft: `4px solid ${address.color}` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{address.display_name}
|
||||||
|
</h3>
|
||||||
|
{address.is_default && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||||
|
<Star className="w-3 h-3" />
|
||||||
|
Default
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{address.is_active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-3">{address.email_address}</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>
|
||||||
|
Processed: <strong>{address.emails_processed_count}</strong> emails
|
||||||
|
</span>
|
||||||
|
{address.last_check_at && (
|
||||||
|
<span>
|
||||||
|
Last checked: {new Date(address.last_check_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!address.is_default && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetDefault(address.id, address.display_name)}
|
||||||
|
disabled={setDefault.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-yellow-600 dark:text-gray-400 dark:hover:text-yellow-400 transition-colors"
|
||||||
|
title="Set as default"
|
||||||
|
>
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTestImap(address.id, address.display_name)}
|
||||||
|
disabled={testImap.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Test IMAP"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleFetchEmails(address.id, address.display_name)}
|
||||||
|
disabled={fetchEmails.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400 transition-colors"
|
||||||
|
title="Fetch emails now"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(address)}
|
||||||
|
className="p-2 text-gray-600 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(address.id, address.display_name)}
|
||||||
|
disabled={deleteAddress.isPending}
|
||||||
|
className="p-2 text-gray-600 hover:text-red-600 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<TicketEmailAddressModal
|
||||||
|
address={editingAddress}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingAddress(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketEmailAddressManager;
|
||||||
508
frontend/src/components/TicketEmailAddressModal.tsx
Normal file
508
frontend/src/components/TicketEmailAddressModal.tsx
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
/**
|
||||||
|
* Ticket Email Address Modal Component
|
||||||
|
* Modal for adding/editing ticket email addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { X, Loader2, ChevronDown, ChevronUp, Save, TestTube } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useCreateTicketEmailAddress,
|
||||||
|
useUpdateTicketEmailAddress,
|
||||||
|
useTicketEmailAddress,
|
||||||
|
useTestImapConnection,
|
||||||
|
useTestSmtpConnection,
|
||||||
|
TicketEmailAddressListItem,
|
||||||
|
TicketEmailAddressCreate,
|
||||||
|
} from '../hooks/useTicketEmailAddresses';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
address?: TicketEmailAddressListItem | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#10b981', // green
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#ef4444', // red
|
||||||
|
'#8b5cf6', // purple
|
||||||
|
'#ec4899', // pink
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#f97316', // orange
|
||||||
|
'#14b8a6', // teal
|
||||||
|
'#6366f1', // indigo
|
||||||
|
];
|
||||||
|
|
||||||
|
const TicketEmailAddressModal: React.FC<Props> = ({ address, onClose }) => {
|
||||||
|
const [formData, setFormData] = useState<TicketEmailAddressCreate>({
|
||||||
|
display_name: '',
|
||||||
|
email_address: '',
|
||||||
|
color: '#3b82f6',
|
||||||
|
imap_host: '',
|
||||||
|
imap_port: 993,
|
||||||
|
imap_use_ssl: true,
|
||||||
|
imap_username: '',
|
||||||
|
imap_password: '',
|
||||||
|
imap_folder: 'INBOX',
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_use_tls: true,
|
||||||
|
smtp_use_ssl: false,
|
||||||
|
smtp_username: '',
|
||||||
|
smtp_password: '',
|
||||||
|
is_active: true,
|
||||||
|
is_default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showImapSection, setShowImapSection] = useState(true);
|
||||||
|
const [showSmtpSection, setShowSmtpSection] = useState(true);
|
||||||
|
|
||||||
|
const createAddress = useCreateTicketEmailAddress();
|
||||||
|
const updateAddress = useUpdateTicketEmailAddress();
|
||||||
|
const { data: fullAddress, isLoading: isLoadingAddress } = useTicketEmailAddress(address?.id || 0);
|
||||||
|
const testImap = useTestImapConnection();
|
||||||
|
const testSmtp = useTestSmtpConnection();
|
||||||
|
|
||||||
|
const isEditing = !!address;
|
||||||
|
|
||||||
|
// Load full address details when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (fullAddress && isEditing) {
|
||||||
|
setFormData({
|
||||||
|
display_name: fullAddress.display_name,
|
||||||
|
email_address: fullAddress.email_address,
|
||||||
|
color: fullAddress.color,
|
||||||
|
imap_host: fullAddress.imap_host,
|
||||||
|
imap_port: fullAddress.imap_port,
|
||||||
|
imap_use_ssl: fullAddress.imap_use_ssl,
|
||||||
|
imap_username: fullAddress.imap_username,
|
||||||
|
imap_password: '', // Don't pre-fill password for security
|
||||||
|
imap_folder: fullAddress.imap_folder,
|
||||||
|
smtp_host: fullAddress.smtp_host,
|
||||||
|
smtp_port: fullAddress.smtp_port,
|
||||||
|
smtp_use_tls: fullAddress.smtp_use_tls,
|
||||||
|
smtp_use_ssl: fullAddress.smtp_use_ssl,
|
||||||
|
smtp_username: fullAddress.smtp_username,
|
||||||
|
smtp_password: '', // Don't pre-fill password for security
|
||||||
|
is_active: fullAddress.is_active,
|
||||||
|
is_default: fullAddress.is_default,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fullAddress, isEditing]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && address) {
|
||||||
|
// For updates, only send changed fields
|
||||||
|
const updateData: Partial<TicketEmailAddressCreate> = { ...formData };
|
||||||
|
// Remove passwords if they're empty (not changed)
|
||||||
|
if (!updateData.imap_password) delete updateData.imap_password;
|
||||||
|
if (!updateData.smtp_password) delete updateData.smtp_password;
|
||||||
|
|
||||||
|
await updateAddress.mutateAsync({ id: address.id, data: updateData });
|
||||||
|
toast.success(`${formData.display_name} updated successfully`);
|
||||||
|
} else {
|
||||||
|
await createAddress.mutateAsync(formData);
|
||||||
|
toast.success(`${formData.display_name} added successfully`);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to save email address');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestImap = async () => {
|
||||||
|
if (!address) {
|
||||||
|
toast.error('Please save the email address first before testing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading('Testing IMAP connection...', { id: 'test-imap' });
|
||||||
|
try {
|
||||||
|
const result = await testImap.mutateAsync(address.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: 'test-imap' });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: 'test-imap' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'IMAP test failed', { id: 'test-imap' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestSmtp = async () => {
|
||||||
|
if (!address) {
|
||||||
|
toast.error('Please save the email address first before testing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.loading('Testing SMTP connection...', { id: 'test-smtp' });
|
||||||
|
try {
|
||||||
|
const result = await testSmtp.mutateAsync(address.id);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message, { id: 'test-smtp' });
|
||||||
|
} else {
|
||||||
|
toast.error(result.message, { id: 'test-smtp' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.message || 'SMTP test failed', { id: 'test-smtp' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing && isLoadingAddress) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{isEditing ? 'Edit Email Address' : 'Add Email Address'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Basic Information</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Display Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.display_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
|
||||||
|
placeholder="e.g., Support, Billing, Sales"
|
||||||
|
required
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email_address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email_address: e.target.value })}
|
||||||
|
placeholder="support@yourcompany.com"
|
||||||
|
required
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Color Tag
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{COLOR_PRESETS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, color })}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||||
|
formData.color === color
|
||||||
|
? 'border-gray-900 dark:border-white scale-110'
|
||||||
|
: 'border-transparent'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={formData.color}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
className="w-8 h-8 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.is_default}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Set as Default</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IMAP Settings */}
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowImapSection(!showImapSection)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
IMAP Settings (Inbound)
|
||||||
|
</h3>
|
||||||
|
{showImapSection ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showImapSection && (
|
||||||
|
<div className="px-4 pb-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Host
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.imap_host}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_host: e.target.value })}
|
||||||
|
placeholder="imap.gmail.com"
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.imap_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_port: parseInt(e.target.value) })}
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.imap_username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_username: e.target.value })}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Password {isEditing && <span className="text-xs text-gray-500">(leave blank to keep current)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.imap_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_password: e.target.value })}
|
||||||
|
placeholder={isEditing ? "••••••••" : "password"}
|
||||||
|
required={!isEditing}
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Folder
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.imap_folder}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_folder: e.target.value })}
|
||||||
|
placeholder="INBOX"
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.imap_use_ssl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, imap_use_ssl: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL/TLS</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestImap}
|
||||||
|
disabled={testImap.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
Test IMAP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMTP Settings */}
|
||||||
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSmtpSection(!showSmtpSection)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
SMTP Settings (Outbound)
|
||||||
|
</h3>
|
||||||
|
{showSmtpSection ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showSmtpSection && (
|
||||||
|
<div className="px-4 pb-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Host
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.smtp_host}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_host: e.target.value })}
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Port
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.smtp_port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_port: parseInt(e.target.value) })}
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.smtp_username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_username: e.target.value })}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Password {isEditing && <span className="text-xs text-gray-500">(leave blank to keep current)</span>}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.smtp_password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_password: e.target.value })}
|
||||||
|
placeholder={isEditing ? "••••••••" : "password"}
|
||||||
|
required={!isEditing}
|
||||||
|
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 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.smtp_use_tls}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_use_tls: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS (Port 587)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.smtp_use_ssl}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smtp_use_ssl: e.target.checked })}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Use SSL/TLS (Port 465)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestSmtp}
|
||||||
|
disabled={testSmtp.isPending}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
Test SMTP
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={createAddress.isPending || updateAddress.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{(createAddress.isPending || updateAddress.isPending) && (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
{isEditing ? 'Update' : 'Add'} Email Address
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketEmailAddressModal;
|
||||||
207
frontend/src/hooks/usePlatformEmailAddresses.ts
Normal file
207
frontend/src/hooks/usePlatformEmailAddresses.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* React Query hooks for Platform Email Addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getPlatformEmailAddresses,
|
||||||
|
getPlatformEmailAddress,
|
||||||
|
createPlatformEmailAddress,
|
||||||
|
updatePlatformEmailAddress,
|
||||||
|
deletePlatformEmailAddress,
|
||||||
|
removeLocalPlatformEmailAddress,
|
||||||
|
syncPlatformEmailAddress,
|
||||||
|
testImapConnection,
|
||||||
|
testSmtpConnection,
|
||||||
|
setAsDefault,
|
||||||
|
testMailServerConnection,
|
||||||
|
getMailServerAccounts,
|
||||||
|
getAvailableDomains,
|
||||||
|
getAssignableUsers,
|
||||||
|
importFromMailServer,
|
||||||
|
PlatformEmailAddressListItem,
|
||||||
|
PlatformEmailAddress,
|
||||||
|
PlatformEmailAddressCreate,
|
||||||
|
PlatformEmailAddressUpdate,
|
||||||
|
} from '../api/platformEmailAddresses';
|
||||||
|
|
||||||
|
export type { PlatformEmailAddressListItem, PlatformEmailAddress };
|
||||||
|
|
||||||
|
const QUERY_KEY = 'platformEmailAddresses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all platform email addresses
|
||||||
|
*/
|
||||||
|
export const usePlatformEmailAddresses = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY],
|
||||||
|
queryFn: getPlatformEmailAddresses,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a single platform email address
|
||||||
|
*/
|
||||||
|
export const usePlatformEmailAddress = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, id],
|
||||||
|
queryFn: () => getPlatformEmailAddress(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new platform email address
|
||||||
|
*/
|
||||||
|
export const useCreatePlatformEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: PlatformEmailAddressCreate) => createPlatformEmailAddress(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update a platform email address
|
||||||
|
*/
|
||||||
|
export const useUpdatePlatformEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: PlatformEmailAddressUpdate }) =>
|
||||||
|
updatePlatformEmailAddress(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a platform email address (also removes from mail server)
|
||||||
|
*/
|
||||||
|
export const useDeletePlatformEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deletePlatformEmailAddress(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to remove email address from database only (keeps mail server account)
|
||||||
|
*/
|
||||||
|
export const useRemoveLocalPlatformEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => removeLocalPlatformEmailAddress(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to sync a platform email address to the mail server
|
||||||
|
*/
|
||||||
|
export const useSyncPlatformEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => syncPlatformEmailAddress(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to test IMAP connection
|
||||||
|
*/
|
||||||
|
export const useTestImapConnection = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => testImapConnection(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to test SMTP connection
|
||||||
|
*/
|
||||||
|
export const useTestSmtpConnection = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => testSmtpConnection(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to set email address as default
|
||||||
|
*/
|
||||||
|
export const useSetAsDefault = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => setAsDefault(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to test mail server SSH connection
|
||||||
|
*/
|
||||||
|
export const useTestMailServerConnection = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: testMailServerConnection,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get mail server accounts
|
||||||
|
*/
|
||||||
|
export const useMailServerAccounts = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'mailServerAccounts'],
|
||||||
|
queryFn: getMailServerAccounts,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get available email domains
|
||||||
|
*/
|
||||||
|
export const useAvailableDomains = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'availableDomains'],
|
||||||
|
queryFn: getAvailableDomains,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get assignable users
|
||||||
|
*/
|
||||||
|
export const useAssignableUsers = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEY, 'assignableUsers'],
|
||||||
|
queryFn: getAssignableUsers,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to import email addresses from the mail server
|
||||||
|
*/
|
||||||
|
export const useImportFromMailServer = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: importFromMailServer,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -15,9 +15,14 @@ export interface PlatformSettings {
|
|||||||
stripe_validation_error: string;
|
stripe_validation_error: string;
|
||||||
has_stripe_keys: boolean;
|
has_stripe_keys: boolean;
|
||||||
stripe_keys_from_env: boolean;
|
stripe_keys_from_env: boolean;
|
||||||
|
email_check_interval_minutes: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GeneralSettingsUpdate {
|
||||||
|
email_check_interval_minutes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripeKeysUpdate {
|
export interface StripeKeysUpdate {
|
||||||
stripe_secret_key?: string;
|
stripe_secret_key?: string;
|
||||||
stripe_publishable_key?: string;
|
stripe_publishable_key?: string;
|
||||||
@@ -35,10 +40,14 @@ export interface SubscriptionPlan {
|
|||||||
price_yearly: string | null;
|
price_yearly: string | null;
|
||||||
business_tier: string;
|
business_tier: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
|
limits: Record<string, any>;
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
transaction_fee_percent: string;
|
transaction_fee_percent: string;
|
||||||
transaction_fee_fixed: string;
|
transaction_fee_fixed: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
|
is_most_popular: boolean;
|
||||||
|
show_price: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -51,10 +60,14 @@ export interface SubscriptionPlanCreate {
|
|||||||
price_yearly?: number | null;
|
price_yearly?: number | null;
|
||||||
business_tier?: string;
|
business_tier?: string;
|
||||||
features?: string[];
|
features?: string[];
|
||||||
|
limits?: Record<string, any>;
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
transaction_fee_percent?: number;
|
transaction_fee_percent?: number;
|
||||||
transaction_fee_fixed?: number;
|
transaction_fee_fixed?: number;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
|
is_most_popular?: boolean;
|
||||||
|
show_price?: boolean;
|
||||||
create_stripe_product?: boolean;
|
create_stripe_product?: boolean;
|
||||||
stripe_product_id?: string;
|
stripe_product_id?: string;
|
||||||
stripe_price_id?: string;
|
stripe_price_id?: string;
|
||||||
@@ -74,6 +87,23 @@ export const usePlatformSettings = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update general platform settings
|
||||||
|
*/
|
||||||
|
export const useUpdateGeneralSettings = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (settings: GeneralSettingsUpdate) => {
|
||||||
|
const { data } = await apiClient.post('/platform/settings/general/', settings);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['platformSettings'], data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to update platform Stripe keys
|
* Hook to update platform Stripe keys
|
||||||
*/
|
*/
|
||||||
@@ -148,7 +178,7 @@ export const useUpdateSubscriptionPlan = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ id, ...updates }: Partial<SubscriptionPlan> & { id: number }) => {
|
mutationFn: async ({ id, ...updates }: Partial<SubscriptionPlanCreate> & { id: number }) => {
|
||||||
const { data } = await apiClient.patch(`/platform/subscription-plans/${id}/`, updates);
|
const { data } = await apiClient.patch(`/platform/subscription-plans/${id}/`, updates);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
141
frontend/src/hooks/useTicketEmailAddresses.ts
Normal file
141
frontend/src/hooks/useTicketEmailAddresses.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* React Query hooks for ticket email addresses
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getTicketEmailAddresses,
|
||||||
|
getTicketEmailAddress,
|
||||||
|
createTicketEmailAddress,
|
||||||
|
updateTicketEmailAddress,
|
||||||
|
deleteTicketEmailAddress,
|
||||||
|
testImapConnection,
|
||||||
|
testSmtpConnection,
|
||||||
|
fetchEmailsNow,
|
||||||
|
setAsDefault,
|
||||||
|
TicketEmailAddress,
|
||||||
|
TicketEmailAddressListItem,
|
||||||
|
TicketEmailAddressCreate,
|
||||||
|
} from '../api/ticketEmailAddresses';
|
||||||
|
|
||||||
|
const QUERY_KEY = 'ticketEmailAddresses';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all ticket email addresses
|
||||||
|
*/
|
||||||
|
export const useTicketEmailAddresses = () => {
|
||||||
|
return useQuery<TicketEmailAddressListItem[]>({
|
||||||
|
queryKey: [QUERY_KEY],
|
||||||
|
queryFn: getTicketEmailAddresses,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a specific ticket email address
|
||||||
|
*/
|
||||||
|
export const useTicketEmailAddress = (id: number) => {
|
||||||
|
return useQuery<TicketEmailAddress>({
|
||||||
|
queryKey: [QUERY_KEY, id],
|
||||||
|
queryFn: () => getTicketEmailAddress(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new ticket email address
|
||||||
|
*/
|
||||||
|
export const useCreateTicketEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: TicketEmailAddressCreate) => createTicketEmailAddress(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update an existing ticket email address
|
||||||
|
*/
|
||||||
|
export const useUpdateTicketEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: Partial<TicketEmailAddressCreate> }) =>
|
||||||
|
updateTicketEmailAddress(id, data),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, variables.id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a ticket email address
|
||||||
|
*/
|
||||||
|
export const useDeleteTicketEmailAddress = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => deleteTicketEmailAddress(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to test IMAP connection
|
||||||
|
*/
|
||||||
|
export const useTestImapConnection = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => testImapConnection(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to test SMTP connection
|
||||||
|
*/
|
||||||
|
export const useTestSmtpConnection = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => testSmtpConnection(id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manually fetch emails
|
||||||
|
*/
|
||||||
|
export const useFetchEmailsNow = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => fetchEmailsNow(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Refresh the email addresses list to update the last_check_at timestamp
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
// Also invalidate tickets query to show any new tickets
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to set an email address as default
|
||||||
|
*/
|
||||||
|
export const useSetAsDefault = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => setAsDefault(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
TicketEmailAddress,
|
||||||
|
TicketEmailAddressListItem,
|
||||||
|
TicketEmailAddressCreate,
|
||||||
|
};
|
||||||
@@ -274,3 +274,19 @@ export const useCannedResponses = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manually refresh/check for new ticket emails
|
||||||
|
*/
|
||||||
|
export const useRefreshTicketEmails = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ticketsApi.refreshTicketEmails,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Refresh tickets list if any emails were processed
|
||||||
|
if (data.processed > 0) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
294
frontend/src/pages/HelpEmailSettings.tsx
Normal file
294
frontend/src/pages/HelpEmailSettings.tsx
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Mail, Server, Lock, Copy, Check, Shield, Smartphone, Monitor, Globe } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CopyButton: React.FC<CopyButtonProps> = ({ text }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="ml-2 p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check size={14} className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy size={14} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
monospace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingRow: React.FC<SettingRowProps> = ({ label, value, monospace = true }) => (
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 text-sm">{label}</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className={`text-gray-900 dark:text-white ${monospace ? 'font-mono text-sm' : ''}`}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<CopyButton text={value} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HelpEmailSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<Mail className="text-brand-600" />
|
||||||
|
{t('help.email.title', 'Email Client Settings')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
{t('help.email.subtitle', 'Configure your email client to send and receive emails using your SmoothSchedule platform email address')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Start */}
|
||||||
|
<div className="bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 rounded-xl p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-brand-900 dark:text-brand-100 mb-3 flex items-center gap-2">
|
||||||
|
<Globe size={20} />
|
||||||
|
Quick Reference
|
||||||
|
</h2>
|
||||||
|
<p className="text-brand-700 dark:text-brand-300 text-sm mb-4">
|
||||||
|
Use these settings to configure any email client. Your username is your full email address, and your password is the one you set when creating the email address.
|
||||||
|
</p>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||||
|
<Server size={16} className="text-blue-500" />
|
||||||
|
Incoming Mail (IMAP)
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<SettingRow label="Server" value="mail.talova.net" />
|
||||||
|
<SettingRow label="Port" value="993" />
|
||||||
|
<SettingRow label="Security" value="SSL/TLS" monospace={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2 flex items-center gap-2">
|
||||||
|
<Server size={16} className="text-green-500" />
|
||||||
|
Outgoing Mail (SMTP)
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<SettingRow label="Server" value="mail.talova.net" />
|
||||||
|
<SettingRow label="Port" value="587" />
|
||||||
|
<SettingRow label="Security" value="STARTTLS" monospace={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security Note */}
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4 mb-8">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Shield className="text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" size={20} />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-yellow-800 dark:text-yellow-200">Security Notice</h3>
|
||||||
|
<p className="text-yellow-700 dark:text-yellow-300 text-sm mt-1">
|
||||||
|
Always ensure your email client is configured to use encrypted connections (SSL/TLS or STARTTLS).
|
||||||
|
Never connect using unencrypted ports (25, 110, 143 without encryption).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Clients */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Monitor size={20} className="text-gray-500" />
|
||||||
|
Desktop Email Clients
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outlook */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Microsoft Outlook</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>Go to <strong>File</strong> > <strong>Add Account</strong></li>
|
||||||
|
<li>Enter your email address and click <strong>Advanced options</strong></li>
|
||||||
|
<li>Check <strong>Let me set up my account manually</strong></li>
|
||||||
|
<li>Select <strong>IMAP</strong></li>
|
||||||
|
<li>Enter the incoming and outgoing server settings from above</li>
|
||||||
|
<li>Enter your password when prompted</li>
|
||||||
|
<li>Click <strong>Connect</strong> to complete setup</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apple Mail */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Apple Mail (macOS)</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>Open <strong>Mail</strong> and go to <strong>Mail</strong> > <strong>Add Account</strong></li>
|
||||||
|
<li>Select <strong>Other Mail Account</strong> and click <strong>Continue</strong></li>
|
||||||
|
<li>Enter your name, email address, and password</li>
|
||||||
|
<li>If automatic setup fails, enter the server settings manually:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Account Type: IMAP</li>
|
||||||
|
<li>Incoming Mail Server: mail.talova.net</li>
|
||||||
|
<li>Outgoing Mail Server: mail.talova.net</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Sign In</strong> to complete</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thunderbird */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Mozilla Thunderbird</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>Go to <strong>Account Settings</strong> > <strong>Account Actions</strong> > <strong>Add Mail Account</strong></li>
|
||||||
|
<li>Enter your name, email address, and password</li>
|
||||||
|
<li>Click <strong>Configure manually</strong></li>
|
||||||
|
<li>Configure incoming server:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Protocol: IMAP</li>
|
||||||
|
<li>Hostname: mail.talova.net</li>
|
||||||
|
<li>Port: 993</li>
|
||||||
|
<li>Connection Security: SSL/TLS</li>
|
||||||
|
<li>Authentication: Normal password</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Configure outgoing server:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Hostname: mail.talova.net</li>
|
||||||
|
<li>Port: 587</li>
|
||||||
|
<li>Connection Security: STARTTLS</li>
|
||||||
|
<li>Authentication: Normal password</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Click <strong>Done</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Clients */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Smartphone size={20} className="text-gray-500" />
|
||||||
|
Mobile Email Apps
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* iOS */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-4">iPhone / iPad (iOS Mail)</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>Go to <strong>Settings</strong> > <strong>Mail</strong> > <strong>Accounts</strong> > <strong>Add Account</strong></li>
|
||||||
|
<li>Select <strong>Other</strong> > <strong>Add Mail Account</strong></li>
|
||||||
|
<li>Enter your name, email, password, and a description</li>
|
||||||
|
<li>Tap <strong>Next</strong> and select <strong>IMAP</strong></li>
|
||||||
|
<li>For <strong>Incoming Mail Server</strong>:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Host Name: mail.talova.net</li>
|
||||||
|
<li>User Name: your full email address</li>
|
||||||
|
<li>Password: your email password</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>For <strong>Outgoing Mail Server</strong>:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Host Name: mail.talova.net</li>
|
||||||
|
<li>User Name: your full email address</li>
|
||||||
|
<li>Password: your email password</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Tap <strong>Save</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Android */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-4">Android (Gmail App)</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>Open the <strong>Gmail</strong> app and tap your profile icon</li>
|
||||||
|
<li>Tap <strong>Add another account</strong> > <strong>Other</strong></li>
|
||||||
|
<li>Enter your email address and tap <strong>Next</strong></li>
|
||||||
|
<li>Select <strong>Personal (IMAP)</strong></li>
|
||||||
|
<li>Enter your password</li>
|
||||||
|
<li>For incoming server settings:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Server: mail.talova.net</li>
|
||||||
|
<li>Port: 993</li>
|
||||||
|
<li>Security type: SSL/TLS</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>For outgoing server settings:
|
||||||
|
<ul className="list-disc list-inside ml-4 mt-2 space-y-1">
|
||||||
|
<li>Server: mail.talova.net</li>
|
||||||
|
<li>Port: 587</li>
|
||||||
|
<li>Security type: STARTTLS</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Complete the setup</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Troubleshooting */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Lock size={20} className="text-gray-500" />
|
||||||
|
Troubleshooting
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2">Cannot connect to server</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Make sure you're using the correct port numbers (993 for IMAP, 587 for SMTP) and that your firewall isn't blocking these ports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2">Authentication failed</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Verify that your username is your full email address (e.g., support@talova.net) and that you're using the correct password.
|
||||||
|
If you've forgotten your password, you can reset it from the Email Addresses page in Platform Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2">Certificate warnings</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
If you see SSL certificate warnings, ensure your device's date and time are correct. The mail server uses a valid SSL certificate that should be trusted by all modern devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 dark:text-white mb-2">Emails not syncing</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Check your sync frequency settings in your email client. Some clients may be set to manual sync by default. Also verify that the email address is active in Platform Settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HelpEmailSettings;
|
||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { Business, User, CustomDomain } from '../types';
|
import { Business, User, CustomDomain } from '../types';
|
||||||
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon } from 'lucide-react';
|
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil, Upload, Image as ImageIcon, Mail } from 'lucide-react';
|
||||||
import DomainPurchase from '../components/DomainPurchase';
|
import DomainPurchase from '../components/DomainPurchase';
|
||||||
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
||||||
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
||||||
@@ -10,6 +10,7 @@ import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '
|
|||||||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||||||
import OnboardingWizard from '../components/OnboardingWizard';
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
import ApiTokensSection from '../components/ApiTokensSection';
|
import ApiTokensSection from '../components/ApiTokensSection';
|
||||||
|
import TicketEmailAddressManager from '../components/TicketEmailAddressManager';
|
||||||
|
|
||||||
// Curated color palettes with complementary primary and secondary colors
|
// Curated color palettes with complementary primary and secondary colors
|
||||||
const colorPalettes = [
|
const colorPalettes = [
|
||||||
@@ -99,7 +100,7 @@ const colorPalettes = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens';
|
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources' | 'api-tokens' | 'email-addresses';
|
||||||
|
|
||||||
// Resource Types Management Section Component
|
// Resource Types Management Section Component
|
||||||
const ResourceTypesSection: React.FC = () => {
|
const ResourceTypesSection: React.FC = () => {
|
||||||
@@ -647,6 +648,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
||||||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||||||
{ id: 'api-tokens' as const, label: 'API Tokens', icon: Key },
|
{ id: 'api-tokens' as const, label: 'API Tokens', icon: Key },
|
||||||
|
{ id: 'email-addresses' as const, label: 'Email Addresses', icon: Mail },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1860,6 +1862,11 @@ const SettingsPage: React.FC = () => {
|
|||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* EMAIL ADDRESSES TAB */}
|
||||||
|
{activeTab === 'email-addresses' && isOwner && (
|
||||||
|
<TicketEmailAddressManager />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Floating Action Buttons */}
|
{/* Floating Action Buttons */}
|
||||||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
||||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
|
|||||||
@@ -224,6 +224,11 @@ const Tickets: React.FC = () => {
|
|||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
onClick={() => openTicketModal(ticket)}
|
onClick={() => openTicketModal(ticket)}
|
||||||
className="bg-white dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all cursor-pointer group"
|
className="bg-white dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all cursor-pointer group"
|
||||||
|
style={{
|
||||||
|
borderLeft: ticket.source_email_address
|
||||||
|
? `4px solid ${ticket.source_email_address.color}`
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -247,6 +252,15 @@ const Tickets: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ticket.source_email_address && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||||
|
style={{ backgroundColor: ticket.source_email_address.color }}
|
||||||
|
>
|
||||||
|
{ticket.source_email_address.display_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
<span className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
<User size={12} />
|
<User size={12} />
|
||||||
{ticket.creatorFullName || ticket.creatorEmail}
|
{ticket.creatorFullName || ticket.creatorEmail}
|
||||||
|
|||||||
187
frontend/src/pages/marketing/PrivacyPolicyPage.tsx
Normal file
187
frontend/src/pages/marketing/PrivacyPolicyPage.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const PrivacyPolicyPage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header Section */}
|
||||||
|
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
|
Privacy Policy
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
Last updated: December 1, 2025
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<section className="py-16 bg-white dark:bg-gray-900">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">1. Introduction</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Welcome to SmoothSchedule. We respect your privacy and are committed to protecting your personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our scheduling platform and services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">2. Information We Collect</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">2.1 Information You Provide</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
We collect information you directly provide to us, including:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Account information (name, email, password, phone number)</li>
|
||||||
|
<li>Business information (business name, subdomain, industry)</li>
|
||||||
|
<li>Payment information (processed securely through third-party payment processors)</li>
|
||||||
|
<li>Customer data you input into the platform (appointments, resources, services)</li>
|
||||||
|
<li>Communications with our support team</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">2.2 Automatically Collected Information</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
When you use our Service, we automatically collect:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Log data (IP address, browser type, device information, operating system)</li>
|
||||||
|
<li>Usage data (pages visited, features used, time spent on platform)</li>
|
||||||
|
<li>Cookie data (session cookies, preference cookies)</li>
|
||||||
|
<li>Performance and error data for service improvement</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">3. How We Use Your Information</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
We use the collected information for:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Providing and maintaining the Service</li>
|
||||||
|
<li>Processing your transactions and managing subscriptions</li>
|
||||||
|
<li>Sending you service updates, security alerts, and administrative messages</li>
|
||||||
|
<li>Responding to your inquiries and providing customer support</li>
|
||||||
|
<li>Improving and optimizing our Service</li>
|
||||||
|
<li>Detecting and preventing fraud and security issues</li>
|
||||||
|
<li>Complying with legal obligations</li>
|
||||||
|
<li>Sending marketing communications (with your consent)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">4. Data Sharing and Disclosure</h2>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">4.1 We Share Data With:</h3>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li><strong>Service Providers:</strong> Third-party vendors who help us provide the Service (hosting, payment processing, analytics)</li>
|
||||||
|
<li><strong>Business Transfers:</strong> In connection with any merger, sale, or acquisition of all or part of our company</li>
|
||||||
|
<li><strong>Legal Requirements:</strong> When required by law, court order, or legal process</li>
|
||||||
|
<li><strong>Protection of Rights:</strong> To protect our rights, property, or safety, or that of our users</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mt-6 mb-3">4.2 We Do NOT:</h3>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Sell your personal data to third parties</li>
|
||||||
|
<li>Share your data for third-party marketing without consent</li>
|
||||||
|
<li>Access your customer data except for support or technical purposes</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">5. Data Security</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
We implement industry-standard security measures to protect your data:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Encryption of data in transit (TLS/SSL)</li>
|
||||||
|
<li>Encryption of sensitive data at rest</li>
|
||||||
|
<li>Regular security audits and vulnerability assessments</li>
|
||||||
|
<li>Access controls and authentication mechanisms</li>
|
||||||
|
<li>Regular backups and disaster recovery procedures</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
However, no method of transmission over the Internet is 100% secure. While we strive to protect your data, we cannot guarantee absolute security.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">6. Data Retention</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
We retain your personal data for as long as necessary to provide the Service and fulfill the purposes described in this policy. When you cancel your account, we retain your data for 30 days to allow for account reactivation. After this period, your personal data may be anonymized and aggregated for internal analytics and service improvement purposes. Anonymized data cannot be used to identify you personally and cannot be retrieved or attributed to any person or account. We may also retain certain data if required for legal or legitimate business purposes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">7. Your Rights and Choices</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Depending on your location, you may have the following rights:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li><strong>Access:</strong> Request a copy of your personal data</li>
|
||||||
|
<li><strong>Correction:</strong> Update or correct inaccurate data</li>
|
||||||
|
<li><strong>Deletion:</strong> Request deletion of your personal data</li>
|
||||||
|
<li><strong>Portability:</strong> Receive your data in a portable format</li>
|
||||||
|
<li><strong>Objection:</strong> Object to certain data processing activities</li>
|
||||||
|
<li><strong>Restriction:</strong> Request restriction of data processing</li>
|
||||||
|
<li><strong>Withdraw Consent:</strong> Withdraw previously given consent</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
To exercise these rights, please contact us at privacy@smoothschedule.com.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">8. Cookies and Tracking</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
We use cookies and similar tracking technologies to:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Maintain your session and keep you logged in</li>
|
||||||
|
<li>Remember your preferences and settings</li>
|
||||||
|
<li>Analyze usage patterns and improve our Service</li>
|
||||||
|
<li>Provide personalized content and features</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
You can control cookies through your browser settings, but disabling cookies may affect your ability to use certain features of the Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">9. Third-Party Services</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Our Service may contain links to third-party websites or integrate with third-party services (OAuth providers, payment processors). We are not responsible for the privacy practices of these third parties. We encourage you to review their privacy policies before providing any personal information.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">10. Children's Privacy</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Our Service is not intended for children under 13 years of age. We do not knowingly collect personal information from children under 13. If you believe we have collected data from a child under 13, please contact us immediately so we can delete it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">11. International Data Transfers</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Your information may be transferred to and processed in countries other than your country of residence. These countries may have different data protection laws. We ensure appropriate safeguards are in place to protect your data in accordance with this Privacy Policy.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">12. California Privacy Rights</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
If you are a California resident, you have additional rights under the California Consumer Privacy Act (CCPA), including the right to know what personal information we collect, the right to delete your information, and the right to opt-out of the sale of your information (which we do not do).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">13. GDPR Compliance</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
If you are in the European Economic Area (EEA), we process your personal data based on legal grounds such as consent, contract performance, legal obligations, or legitimate interests. You have rights under the General Data Protection Regulation (GDPR) including the right to lodge a complaint with a supervisory authority.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">14. Changes to This Privacy Policy</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
We may update this Privacy Policy from time to time. We will notify you of material changes by posting the new policy on this page and updating the "Last updated" date. We encourage you to review this Privacy Policy periodically.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">15. Contact Us</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
If you have any questions about this Privacy Policy or our data practices, please contact us:
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<strong>Email:</strong> privacy@smoothschedule.com
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<strong>Data Protection Officer:</strong> dpo@smoothschedule.com
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<strong>Website:</strong> https://smoothschedule.com/contact
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivacyPolicyPage;
|
||||||
136
frontend/src/pages/marketing/TermsOfServicePage.tsx
Normal file
136
frontend/src/pages/marketing/TermsOfServicePage.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TermsOfServicePage: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header Section */}
|
||||||
|
<section className="py-20 lg:py-28 bg-gradient-to-br from-white via-brand-50/30 to-white dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-6">
|
||||||
|
Terms of Service
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 dark:text-gray-400">
|
||||||
|
Last updated: December 1, 2025
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Content Section */}
|
||||||
|
<section className="py-16 bg-white dark:bg-gray-900">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="prose prose-lg dark:prose-invert max-w-none">
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">1. Acceptance of Terms</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
By accessing and using SmoothSchedule ("the Service"), you accept and agree to be bound by the terms and provision of this agreement. If you do not agree to these Terms of Service, please do not use the Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">2. Description of Service</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
SmoothSchedule is a multi-tenant scheduling platform that enables businesses to manage appointments, resources, services, and customer interactions. The Service is provided on a subscription basis with various pricing tiers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">3. User Accounts</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
To use the Service, you must:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Create an account with accurate and complete information</li>
|
||||||
|
<li>Maintain the security of your account credentials</li>
|
||||||
|
<li>Notify us immediately of any unauthorized access</li>
|
||||||
|
<li>Be responsible for all activities under your account</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">4. Acceptable Use</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
You agree not to use the Service to:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Violate any applicable laws or regulations</li>
|
||||||
|
<li>Infringe on intellectual property rights</li>
|
||||||
|
<li>Transmit malicious code or interfere with the Service</li>
|
||||||
|
<li>Attempt to gain unauthorized access to any part of the Service</li>
|
||||||
|
<li>Use the Service for any fraudulent or illegal purpose</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">5. Subscriptions and Payments</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Subscription terms:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-6 text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<li>Subscriptions are billed in advance on a recurring basis</li>
|
||||||
|
<li>You may cancel your subscription at any time</li>
|
||||||
|
<li>No refunds are provided for partial subscription periods</li>
|
||||||
|
<li>We reserve the right to change pricing with 30 days notice</li>
|
||||||
|
<li>Failed payments may result in service suspension</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">6. Trial Period</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
We may offer a free trial period. At the end of the trial, your subscription will automatically convert to a paid plan unless you cancel. Trial terms may vary and are subject to change.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">7. Data and Privacy</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Your use of the Service is also governed by our Privacy Policy. We collect, use, and protect your data as described in that policy. You retain ownership of all data you input into the Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">8. Service Availability</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
While we strive for 99.9% uptime, we do not guarantee uninterrupted access to the Service. We may perform maintenance, updates, or modifications that temporarily affect availability. We are not liable for any downtime or service interruptions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">9. Intellectual Property</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The Service, including all software, designs, text, graphics, and other content, is owned by SmoothSchedule and protected by copyright, trademark, and other intellectual property laws. You may not copy, modify, distribute, or create derivative works without our express written permission.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">10. Termination</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
We may terminate or suspend your account and access to the Service at any time, with or without cause, with or without notice. Upon termination, your right to use the Service will immediately cease. We will retain your data for 30 days after termination, after which it may be permanently deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">11. Limitation of Liability</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
To the maximum extent permitted by law, SmoothSchedule shall not be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses resulting from your use of the Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">12. Warranty Disclaimer</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The Service is provided "as is" and "as available" without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, or non-infringement.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">13. Indemnification</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
You agree to indemnify and hold harmless SmoothSchedule, its officers, directors, employees, and agents from any claims, damages, losses, liabilities, and expenses (including legal fees) arising from your use of the Service or violation of these Terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">14. Changes to Terms</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
We reserve the right to modify these Terms at any time. We will notify you of material changes via email or through the Service. Your continued use of the Service after such changes constitutes acceptance of the new Terms.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">15. Governing Law</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which SmoothSchedule is registered, without regard to its conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mt-8 mb-4">16. Contact Us</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
If you have any questions about these Terms of Service, please contact us at:
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<strong>Email:</strong> legal@smoothschedule.com
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
<strong>Website:</strong> https://smoothschedule.com/contact
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TermsOfServicePage;
|
||||||
34
frontend/src/pages/platform/PlatformEmailAddresses.tsx
Normal file
34
frontend/src/pages/platform/PlatformEmailAddresses.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Platform Email Addresses Management Page
|
||||||
|
* Allows platform admins to manage platform-wide email addresses hosted on mail.talova.net
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Mail } from 'lucide-react';
|
||||||
|
import PlatformEmailAddressManager from '../../components/PlatformEmailAddressManager';
|
||||||
|
|
||||||
|
const PlatformEmailAddresses: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<Mail className="w-6 h-6" />
|
||||||
|
Platform Email Addresses
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Manage platform-wide email addresses hosted on mail.talova.net.
|
||||||
|
These addresses are used for platform-level support and are automatically synced to the mail server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Address Manager */}
|
||||||
|
<PlatformEmailAddressManager />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlatformEmailAddresses;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus } from 'lucide-react';
|
import { Ticket as TicketIcon, AlertCircle, CheckCircle2, Clock, Circle, Loader2, XCircle, Plus, RefreshCw } from 'lucide-react';
|
||||||
import { useTickets } from '../../hooks/useTickets';
|
import { useTickets, useRefreshTicketEmails } from '../../hooks/useTickets';
|
||||||
import { useTicketWebSocket } from '../../hooks/useTicketWebSocket';
|
import { useTicketWebSocket } from '../../hooks/useTicketWebSocket';
|
||||||
import { Ticket, TicketStatus } from '../../types';
|
import { Ticket, TicketStatus } from '../../types';
|
||||||
import TicketModal from '../../components/TicketModal';
|
import TicketModal from '../../components/TicketModal';
|
||||||
import Portal from '../../components/Portal';
|
import Portal from '../../components/Portal';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const PlatformSupport: React.FC = () => {
|
const PlatformSupport: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -16,13 +17,49 @@ const PlatformSupport: React.FC = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
const [statusFilter, setStatusFilter] = useState<TicketStatus | 'ALL'>('ALL');
|
||||||
|
|
||||||
// Fetch all tickets (platform admins see all)
|
// Fetch all tickets (platform admins see all) - always fetch all for counts
|
||||||
const { data: tickets = [], isLoading, error } = useTickets(
|
const { data: allTickets = [], isLoading, error, refetch } = useTickets();
|
||||||
statusFilter !== 'ALL' ? { status: statusFilter } : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter to show PLATFORM tickets primarily, but also show all for platform admins
|
// Refresh emails mutation
|
||||||
const platformTickets = tickets;
|
const refreshEmails = useRefreshTicketEmails();
|
||||||
|
|
||||||
|
// Compute ticket counts by status
|
||||||
|
const ticketCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {
|
||||||
|
ALL: allTickets.length,
|
||||||
|
OPEN: 0,
|
||||||
|
IN_PROGRESS: 0,
|
||||||
|
AWAITING_RESPONSE: 0,
|
||||||
|
RESOLVED: 0,
|
||||||
|
CLOSED: 0,
|
||||||
|
};
|
||||||
|
allTickets.forEach((ticket) => {
|
||||||
|
if (counts[ticket.status] !== undefined) {
|
||||||
|
counts[ticket.status]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [allTickets]);
|
||||||
|
|
||||||
|
// Filter tickets based on selected status
|
||||||
|
const platformTickets = useMemo(() => {
|
||||||
|
if (statusFilter === 'ALL') return allTickets;
|
||||||
|
return allTickets.filter((ticket) => ticket.status === statusFilter);
|
||||||
|
}, [allTickets, statusFilter]);
|
||||||
|
|
||||||
|
const handleRefreshEmails = async () => {
|
||||||
|
try {
|
||||||
|
const result = await refreshEmails.mutateAsync();
|
||||||
|
if (result.processed > 0) {
|
||||||
|
toast.success(`Processed ${result.processed} new email(s)`);
|
||||||
|
refetch();
|
||||||
|
} else {
|
||||||
|
toast.success('No new emails found');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to refresh emails');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: TicketStatus) => {
|
const getStatusIcon = (status: TicketStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -85,13 +122,24 @@ const PlatformSupport: React.FC = () => {
|
|||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.supportTickets')}</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('platform.supportTickets')}</h2>
|
||||||
<p className="text-gray-500 dark:text-gray-400">{t('platform.supportDescription')}</p>
|
<p className="text-gray-500 dark:text-gray-400">{t('platform.supportDescription')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleNewTicket}
|
<button
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
onClick={handleRefreshEmails}
|
||||||
>
|
disabled={refreshEmails.isPending}
|
||||||
<Plus size={18} />
|
className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||||
{t('tickets.newTicket')}
|
title="Check for new emails"
|
||||||
</button>
|
>
|
||||||
|
<RefreshCw size={18} className={refreshEmails.isPending ? 'animate-spin' : ''} />
|
||||||
|
{refreshEmails.isPending ? 'Checking...' : 'Check Emails'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNewTicket}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
{t('tickets.newTicket')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Tabs */}
|
{/* Status Tabs */}
|
||||||
@@ -100,13 +148,22 @@ const PlatformSupport: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
onClick={() => setStatusFilter(tab.key)}
|
onClick={() => setStatusFilter(tab.key)}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors flex items-center gap-2 ${
|
||||||
statusFilter === tab.key
|
statusFilter === tab.key
|
||||||
? 'bg-brand-600 text-white'
|
? 'bg-brand-600 text-white'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
|
{ticketCounts[tab.key] > 0 && (
|
||||||
|
<span className={`px-1.5 py-0.5 text-xs rounded-full ${
|
||||||
|
statusFilter === tab.key
|
||||||
|
? 'bg-white/20 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{ticketCounts[tab.key]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +185,11 @@ const PlatformSupport: React.FC = () => {
|
|||||||
key={ticket.id}
|
key={ticket.id}
|
||||||
onClick={() => handleTicketClick(ticket)}
|
onClick={() => handleTicketClick(ticket)}
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
style={{
|
||||||
|
borderLeft: ticket.source_email_address
|
||||||
|
? `4px solid ${ticket.source_email_address.color}`
|
||||||
|
: undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
@@ -147,6 +209,14 @@ const PlatformSupport: React.FC = () => {
|
|||||||
}`}>
|
}`}>
|
||||||
{t(`tickets.types.${ticket.ticketType.toLowerCase()}`)}
|
{t(`tickets.types.${ticket.ticketType.toLowerCase()}`)}
|
||||||
</span>
|
</span>
|
||||||
|
{ticket.source_email_address && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||||
|
style={{ backgroundColor: ticket.source_email_address.color }}
|
||||||
|
>
|
||||||
|
{ticket.source_email_address.display_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.creatorFullName || ticket.creatorEmail}</span>
|
{t('platform.reportedBy')} <span className="font-medium text-gray-900 dark:text-white">{ticket.creatorFullName || ticket.creatorEmail}</span>
|
||||||
|
|||||||
@@ -210,6 +210,15 @@ export interface TicketComment {
|
|||||||
isInternal: boolean;
|
isInternal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TicketEmailAddressListItem {
|
||||||
|
id: number;
|
||||||
|
display_name: string;
|
||||||
|
email_address: string;
|
||||||
|
color: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Ticket {
|
export interface Ticket {
|
||||||
id: string;
|
id: string;
|
||||||
tenant?: string; // Tenant ID, optional for platform tickets
|
tenant?: string; // Tenant ID, optional for platform tickets
|
||||||
@@ -236,6 +245,8 @@ export interface Ticket {
|
|||||||
// External sender info (for tickets from non-registered users via email)
|
// External sender info (for tickets from non-registered users via email)
|
||||||
externalEmail?: string;
|
externalEmail?: string;
|
||||||
externalName?: string;
|
externalName?: string;
|
||||||
|
// Source email address (which email address received/sent this ticket)
|
||||||
|
source_email_address?: TicketEmailAddressListItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TicketTemplate {
|
export interface TicketTemplate {
|
||||||
|
|||||||
3
smoothschedule/.gitignore
vendored
3
smoothschedule/.gitignore
vendored
@@ -276,3 +276,6 @@ smoothschedule/media/
|
|||||||
.env
|
.env
|
||||||
.envs/*
|
.envs/*
|
||||||
!.envs/.local/
|
!.envs/.local/
|
||||||
|
|
||||||
|
# SSH keys for mail server access
|
||||||
|
.ssh/
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ from schedule.api_views import (
|
|||||||
custom_domain_verify_view, custom_domain_set_primary_view,
|
custom_domain_verify_view, custom_domain_set_primary_view,
|
||||||
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
|
sandbox_status_view, sandbox_toggle_view, sandbox_reset_view
|
||||||
)
|
)
|
||||||
|
from core.email_autoconfig import (
|
||||||
|
MozillaAutoconfigView,
|
||||||
|
MicrosoftAutodiscoverView,
|
||||||
|
AppleConfigProfileView,
|
||||||
|
WellKnownAutoconfigView,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
@@ -39,6 +45,14 @@ urlpatterns = [
|
|||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
# Django Hijack (masquerade) - for admin interface
|
# Django Hijack (masquerade) - for admin interface
|
||||||
path("hijack/", include("hijack.urls")),
|
path("hijack/", include("hijack.urls")),
|
||||||
|
|
||||||
|
# Email Autoconfiguration (for email clients)
|
||||||
|
path("mail/config-v1.1.xml", MozillaAutoconfigView.as_view(), name="autoconfig"),
|
||||||
|
path(".well-known/autoconfig/mail/config-v1.1.xml", WellKnownAutoconfigView.as_view(), name="autoconfig-wellknown"),
|
||||||
|
path("autodiscover/autodiscover.xml", MicrosoftAutodiscoverView.as_view(), name="autodiscover"),
|
||||||
|
path("Autodiscover/Autodiscover.xml", MicrosoftAutodiscoverView.as_view(), name="autodiscover-caps"),
|
||||||
|
path("email/apple-profile.mobileconfig", AppleConfigProfileView.as_view(), name="apple-config"),
|
||||||
|
|
||||||
# Your stuff: custom urls includes go here
|
# Your stuff: custom urls includes go here
|
||||||
# ...
|
# ...
|
||||||
# Media files
|
# Media files
|
||||||
|
|||||||
250
smoothschedule/core/email_autoconfig.py
Normal file
250
smoothschedule/core/email_autoconfig.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""
|
||||||
|
Email Autoconfiguration Views
|
||||||
|
|
||||||
|
Provides automatic email client configuration for:
|
||||||
|
- Mozilla Autoconfig (Thunderbird, etc.)
|
||||||
|
- Microsoft Autodiscover (Outlook)
|
||||||
|
- Apple Mail configuration profile
|
||||||
|
|
||||||
|
These endpoints allow email clients to automatically configure
|
||||||
|
IMAP and SMTP settings for smoothschedule.com email addresses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.views import View
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
|
class MozillaAutoconfigView(View):
|
||||||
|
"""
|
||||||
|
Mozilla Autoconfig endpoint.
|
||||||
|
|
||||||
|
URL: /mail/config-v1.1.xml or /.well-known/autoconfig/mail/config-v1.1.xml
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- Mozilla Thunderbird
|
||||||
|
- Evolution
|
||||||
|
- Other clients supporting Mozilla autoconfig
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Get email address from query parameter
|
||||||
|
email = request.GET.get('emailaddress', '')
|
||||||
|
|
||||||
|
# Extract local part if email provided
|
||||||
|
if '@' in email:
|
||||||
|
local_part = email.split('@')[0]
|
||||||
|
else:
|
||||||
|
local_part = '%EMAILLOCALPART%'
|
||||||
|
|
||||||
|
xml_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<clientConfig version="1.1">
|
||||||
|
<emailProvider id="smoothschedule.com">
|
||||||
|
<domain>smoothschedule.com</domain>
|
||||||
|
<displayName>SmoothSchedule Mail</displayName>
|
||||||
|
<displayShortName>SmoothSchedule</displayShortName>
|
||||||
|
|
||||||
|
<incomingServer type="imap">
|
||||||
|
<hostname>mail.talova.net</hostname>
|
||||||
|
<port>993</port>
|
||||||
|
<socketType>SSL</socketType>
|
||||||
|
<authentication>password-cleartext</authentication>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</incomingServer>
|
||||||
|
|
||||||
|
<outgoingServer type="smtp">
|
||||||
|
<hostname>mail.talova.net</hostname>
|
||||||
|
<port>587</port>
|
||||||
|
<socketType>STARTTLS</socketType>
|
||||||
|
<authentication>password-cleartext</authentication>
|
||||||
|
<username>%EMAILADDRESS%</username>
|
||||||
|
</outgoingServer>
|
||||||
|
|
||||||
|
<documentation url="https://smoothschedule.com/help/email">
|
||||||
|
<descr lang="en">Email configuration help</descr>
|
||||||
|
</documentation>
|
||||||
|
</emailProvider>
|
||||||
|
</clientConfig>'''
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
xml_content,
|
||||||
|
content_type='application/xml; charset=utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
|
class MicrosoftAutodiscoverView(View):
|
||||||
|
"""
|
||||||
|
Microsoft Autodiscover endpoint.
|
||||||
|
|
||||||
|
URL: /autodiscover/autodiscover.xml
|
||||||
|
|
||||||
|
Used by:
|
||||||
|
- Microsoft Outlook
|
||||||
|
- Windows Mail
|
||||||
|
- Other Microsoft clients
|
||||||
|
"""
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
# Microsoft Autodiscover uses POST with XML body
|
||||||
|
# Extract email from request body if needed
|
||||||
|
|
||||||
|
xml_content = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||||
|
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
|
||||||
|
<Account>
|
||||||
|
<AccountType>email</AccountType>
|
||||||
|
<Action>settings</Action>
|
||||||
|
<Protocol>
|
||||||
|
<Type>IMAP</Type>
|
||||||
|
<Server>mail.talova.net</Server>
|
||||||
|
<Port>993</Port>
|
||||||
|
<SSL>on</SSL>
|
||||||
|
<AuthRequired>on</AuthRequired>
|
||||||
|
<LoginName></LoginName>
|
||||||
|
</Protocol>
|
||||||
|
<Protocol>
|
||||||
|
<Type>SMTP</Type>
|
||||||
|
<Server>mail.talova.net</Server>
|
||||||
|
<Port>587</Port>
|
||||||
|
<SSL>on</SSL>
|
||||||
|
<Encryption>TLS</Encryption>
|
||||||
|
<AuthRequired>on</AuthRequired>
|
||||||
|
<LoginName></LoginName>
|
||||||
|
</Protocol>
|
||||||
|
</Account>
|
||||||
|
</Response>
|
||||||
|
</Autodiscover>'''
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
xml_content,
|
||||||
|
content_type='application/xml; charset=utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Some clients may use GET
|
||||||
|
return self.post(request)
|
||||||
|
|
||||||
|
|
||||||
|
class AppleConfigProfileView(View):
|
||||||
|
"""
|
||||||
|
Apple Configuration Profile for iOS/macOS Mail.
|
||||||
|
|
||||||
|
URL: /email/apple-profile.mobileconfig
|
||||||
|
|
||||||
|
Provides a downloadable .mobileconfig file that can be
|
||||||
|
installed on iOS/macOS devices for automatic email setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
email = request.GET.get('email', '')
|
||||||
|
|
||||||
|
if not email or '@' not in email:
|
||||||
|
return HttpResponse(
|
||||||
|
'Email parameter required (e.g., ?email=user@smoothschedule.com)',
|
||||||
|
status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate a unique identifier for this profile
|
||||||
|
import uuid
|
||||||
|
profile_uuid = str(uuid.uuid4()).upper()
|
||||||
|
account_uuid = str(uuid.uuid4()).upper()
|
||||||
|
|
||||||
|
local_part = email.split('@')[0]
|
||||||
|
display_name = local_part.replace('.', ' ').replace('-', ' ').title()
|
||||||
|
|
||||||
|
plist_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>PayloadContent</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>EmailAccountDescription</key>
|
||||||
|
<string>SmoothSchedule Mail</string>
|
||||||
|
<key>EmailAccountName</key>
|
||||||
|
<string>{display_name}</string>
|
||||||
|
<key>EmailAccountType</key>
|
||||||
|
<string>EmailTypeIMAP</string>
|
||||||
|
<key>EmailAddress</key>
|
||||||
|
<string>{email}</string>
|
||||||
|
<key>IncomingMailServerAuthentication</key>
|
||||||
|
<string>EmailAuthPassword</string>
|
||||||
|
<key>IncomingMailServerHostName</key>
|
||||||
|
<string>mail.talova.net</string>
|
||||||
|
<key>IncomingMailServerPortNumber</key>
|
||||||
|
<integer>993</integer>
|
||||||
|
<key>IncomingMailServerUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>IncomingMailServerUsername</key>
|
||||||
|
<string>{email}</string>
|
||||||
|
<key>OutgoingMailServerAuthentication</key>
|
||||||
|
<string>EmailAuthPassword</string>
|
||||||
|
<key>OutgoingMailServerHostName</key>
|
||||||
|
<string>mail.talova.net</string>
|
||||||
|
<key>OutgoingMailServerPortNumber</key>
|
||||||
|
<integer>587</integer>
|
||||||
|
<key>OutgoingMailServerUseSSL</key>
|
||||||
|
<true/>
|
||||||
|
<key>OutgoingMailServerUsername</key>
|
||||||
|
<string>{email}</string>
|
||||||
|
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||||
|
<true/>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Configures email account for {email}</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>SmoothSchedule Email</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.smoothschedule.email.account.{account_uuid}</string>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>com.apple.mail.managed</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>{account_uuid}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>SMIMEEnablePerMessageSwitch</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>PayloadDescription</key>
|
||||||
|
<string>Email configuration for SmoothSchedule</string>
|
||||||
|
<key>PayloadDisplayName</key>
|
||||||
|
<string>SmoothSchedule Email Configuration</string>
|
||||||
|
<key>PayloadIdentifier</key>
|
||||||
|
<string>com.smoothschedule.email.profile.{profile_uuid}</string>
|
||||||
|
<key>PayloadOrganization</key>
|
||||||
|
<string>SmoothSchedule</string>
|
||||||
|
<key>PayloadRemovalDisallowed</key>
|
||||||
|
<false/>
|
||||||
|
<key>PayloadType</key>
|
||||||
|
<string>Configuration</string>
|
||||||
|
<key>PayloadUUID</key>
|
||||||
|
<string>{profile_uuid}</string>
|
||||||
|
<key>PayloadVersion</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</plist>'''
|
||||||
|
|
||||||
|
response = HttpResponse(
|
||||||
|
plist_content,
|
||||||
|
content_type='application/x-apple-aspen-config'
|
||||||
|
)
|
||||||
|
response['Content-Disposition'] = f'attachment; filename="smoothschedule-email.mobileconfig"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class WellKnownAutoconfigView(View):
|
||||||
|
"""
|
||||||
|
.well-known autoconfig redirect.
|
||||||
|
|
||||||
|
Some clients look for /.well-known/autoconfig/mail/config-v1.1.xml
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
email = request.GET.get('emailaddress', '')
|
||||||
|
url = '/mail/config-v1.1.xml'
|
||||||
|
if email:
|
||||||
|
url += f'?emailaddress={email}'
|
||||||
|
return redirect(url)
|
||||||
349
smoothschedule/platform_admin/mail_server.py
Normal file
349
smoothschedule/platform_admin/mail_server.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"""
|
||||||
|
Mail Server Service for managing email accounts on mail.talova.net.
|
||||||
|
|
||||||
|
This service manages email accounts via SSH commands to the docker-mailserver
|
||||||
|
container running on mail.talova.net.
|
||||||
|
|
||||||
|
Commands used:
|
||||||
|
- docker exec mailserver setup email add user@domain password
|
||||||
|
- docker exec mailserver setup email update user@domain password
|
||||||
|
- docker exec mailserver setup email del user@domain
|
||||||
|
- docker exec mailserver setup email list
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import shlex
|
||||||
|
from typing import Optional
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerError(Exception):
|
||||||
|
"""Exception raised when mail server operations fail."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MailServerService:
|
||||||
|
"""
|
||||||
|
Service for managing email accounts on the mail.talova.net server.
|
||||||
|
|
||||||
|
Uses SSH to execute docker commands on the remote mail server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Mail server configuration
|
||||||
|
SSH_HOST = 'mail.talova.net'
|
||||||
|
SSH_USER = 'poduck'
|
||||||
|
DOCKER_CONTAINER = 'mailserver'
|
||||||
|
DOCKER_COMPOSE_PATH = '~/docker-mailserver'
|
||||||
|
|
||||||
|
# SSH key configuration (persistent keys mounted in container)
|
||||||
|
SSH_KEY_PATH = '/app/.ssh/id_ed25519'
|
||||||
|
SSH_KNOWN_HOSTS_PATH = '/app/.ssh/known_hosts'
|
||||||
|
|
||||||
|
# SSH connection timeout (seconds)
|
||||||
|
SSH_TIMEOUT = 30
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ssh_host = getattr(settings, 'MAIL_SERVER_SSH_HOST', self.SSH_HOST)
|
||||||
|
self.ssh_user = getattr(settings, 'MAIL_SERVER_SSH_USER', self.SSH_USER)
|
||||||
|
self.docker_container = getattr(settings, 'MAIL_SERVER_DOCKER_CONTAINER', self.DOCKER_CONTAINER)
|
||||||
|
self.ssh_key_path = getattr(settings, 'MAIL_SERVER_SSH_KEY_PATH', self.SSH_KEY_PATH)
|
||||||
|
self.ssh_known_hosts_path = getattr(settings, 'MAIL_SERVER_SSH_KNOWN_HOSTS_PATH', self.SSH_KNOWN_HOSTS_PATH)
|
||||||
|
|
||||||
|
def _run_ssh_command(self, command: str, timeout: Optional[int] = None) -> tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Execute a command on the mail server via SSH.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: The command to execute on the remote server
|
||||||
|
timeout: Optional timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, stdout, stderr)
|
||||||
|
"""
|
||||||
|
timeout = timeout or self.SSH_TIMEOUT
|
||||||
|
|
||||||
|
ssh_command = [
|
||||||
|
'ssh',
|
||||||
|
'-i', self.ssh_key_path,
|
||||||
|
'-o', 'StrictHostKeyChecking=accept-new',
|
||||||
|
'-o', f'UserKnownHostsFile={self.ssh_known_hosts_path}',
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'BatchMode=yes',
|
||||||
|
f'{self.ssh_user}@{self.ssh_host}',
|
||||||
|
command
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Executing SSH command: {' '.join(ssh_command[:6])} [command hidden]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
ssh_command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
success = result.returncode == 0
|
||||||
|
stdout = result.stdout.strip()
|
||||||
|
stderr = result.stderr.strip()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
logger.warning(f"SSH command failed: {stderr}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"SSH command succeeded: {stdout[:100]}...")
|
||||||
|
|
||||||
|
return success, stdout, stderr
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"SSH command timed out after {timeout}s")
|
||||||
|
return False, '', f'Command timed out after {timeout} seconds'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SSH command error: {str(e)}")
|
||||||
|
return False, '', str(e)
|
||||||
|
|
||||||
|
def _run_docker_command(self, docker_args: str) -> tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
Execute a docker command on the mail server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
docker_args: Arguments to pass to docker exec
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, stdout, stderr)
|
||||||
|
"""
|
||||||
|
command = f"docker exec {self.docker_container} {docker_args}"
|
||||||
|
return self._run_ssh_command(command)
|
||||||
|
|
||||||
|
def list_accounts(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
List all email accounts on the mail server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of account dictionaries with email and quota info
|
||||||
|
"""
|
||||||
|
success, stdout, stderr = self._run_docker_command('setup email list')
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise MailServerError(f"Failed to list accounts: {stderr}")
|
||||||
|
|
||||||
|
accounts = []
|
||||||
|
for line in stdout.split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('*'):
|
||||||
|
# Parse format: "* user@domain ( size / quota ) [percent%]"
|
||||||
|
if line.startswith('*'):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
email = parts[1]
|
||||||
|
accounts.append({
|
||||||
|
'email': email,
|
||||||
|
'raw_line': line
|
||||||
|
})
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
def account_exists(self, email: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an email account exists on the mail server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the account exists, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
accounts = self.list_accounts()
|
||||||
|
return any(acc['email'].lower() == email.lower() for acc in accounts)
|
||||||
|
except MailServerError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_account(self, email: str, password: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Create a new email account on the mail server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to create
|
||||||
|
password: The password for the account
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
# Validate email format
|
||||||
|
if '@' not in email:
|
||||||
|
return False, 'Invalid email format'
|
||||||
|
|
||||||
|
# Check if account already exists
|
||||||
|
if self.account_exists(email):
|
||||||
|
return False, f'Account {email} already exists'
|
||||||
|
|
||||||
|
# Escape password for shell (use single quotes and escape any single quotes in password)
|
||||||
|
escaped_password = password.replace("'", "'\"'\"'")
|
||||||
|
|
||||||
|
# Create the account
|
||||||
|
command = f"setup email add {shlex.quote(email)} '{escaped_password}'"
|
||||||
|
success, stdout, stderr = self._run_docker_command(command)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Created email account: {email}")
|
||||||
|
return True, f'Successfully created account {email}'
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to create account {email}: {stderr}")
|
||||||
|
return False, f'Failed to create account: {stderr}'
|
||||||
|
|
||||||
|
def update_password(self, email: str, new_password: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Update the password for an existing email account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to update
|
||||||
|
new_password: The new password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
# Check if account exists
|
||||||
|
if not self.account_exists(email):
|
||||||
|
return False, f'Account {email} does not exist'
|
||||||
|
|
||||||
|
# Escape password for shell
|
||||||
|
escaped_password = new_password.replace("'", "'\"'\"'")
|
||||||
|
|
||||||
|
# Update the password
|
||||||
|
command = f"setup email update {shlex.quote(email)} '{escaped_password}'"
|
||||||
|
success, stdout, stderr = self._run_docker_command(command)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Updated password for: {email}")
|
||||||
|
return True, f'Successfully updated password for {email}'
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to update password for {email}: {stderr}")
|
||||||
|
return False, f'Failed to update password: {stderr}'
|
||||||
|
|
||||||
|
def delete_account(self, email: str) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Delete an email account from the mail server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: The email address to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
# Check if account exists
|
||||||
|
if not self.account_exists(email):
|
||||||
|
return False, f'Account {email} does not exist'
|
||||||
|
|
||||||
|
# Delete the account (use -y to confirm deletion)
|
||||||
|
command = f"setup email del -y {shlex.quote(email)}"
|
||||||
|
success, stdout, stderr = self._run_docker_command(command)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Deleted email account: {email}")
|
||||||
|
return True, f'Successfully deleted account {email}'
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to delete account {email}: {stderr}")
|
||||||
|
return False, f'Failed to delete account: {stderr}'
|
||||||
|
|
||||||
|
def test_connection(self) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Test SSH connection to the mail server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
success, stdout, stderr = self._run_ssh_command('echo "Connection successful"')
|
||||||
|
|
||||||
|
if success and 'Connection successful' in stdout:
|
||||||
|
return True, 'Successfully connected to mail server'
|
||||||
|
else:
|
||||||
|
return False, f'Failed to connect: {stderr or "Unknown error"}'
|
||||||
|
|
||||||
|
def sync_account(self, platform_email) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Sync a PlatformEmailAddress to the mail server.
|
||||||
|
|
||||||
|
Creates the account if it doesn't exist, or updates the password if it does.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platform_email: PlatformEmailAddress instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
email = platform_email.email_address
|
||||||
|
password = platform_email.password
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.account_exists(email):
|
||||||
|
# Update existing account password
|
||||||
|
success, message = self.update_password(email, password)
|
||||||
|
else:
|
||||||
|
# Create new account
|
||||||
|
success, message = self.create_account(email, password)
|
||||||
|
|
||||||
|
# Update sync status
|
||||||
|
platform_email.mail_server_synced = success
|
||||||
|
platform_email.last_synced_at = timezone.now()
|
||||||
|
if not success:
|
||||||
|
platform_email.last_sync_error = message
|
||||||
|
else:
|
||||||
|
platform_email.last_sync_error = ''
|
||||||
|
platform_email.save(update_fields=[
|
||||||
|
'mail_server_synced',
|
||||||
|
'last_synced_at',
|
||||||
|
'last_sync_error'
|
||||||
|
])
|
||||||
|
|
||||||
|
return success, message
|
||||||
|
|
||||||
|
except MailServerError as e:
|
||||||
|
platform_email.mail_server_synced = False
|
||||||
|
platform_email.last_sync_error = str(e)
|
||||||
|
platform_email.last_synced_at = timezone.now()
|
||||||
|
platform_email.save(update_fields=[
|
||||||
|
'mail_server_synced',
|
||||||
|
'last_synced_at',
|
||||||
|
'last_sync_error'
|
||||||
|
])
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def delete_and_unsync(self, platform_email) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Delete a PlatformEmailAddress from the mail server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platform_email: PlatformEmailAddress instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message)
|
||||||
|
"""
|
||||||
|
email = platform_email.email_address
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.account_exists(email):
|
||||||
|
success, message = self.delete_account(email)
|
||||||
|
return success, message
|
||||||
|
else:
|
||||||
|
# Account doesn't exist on server, that's fine
|
||||||
|
return True, f'Account {email} was not on mail server'
|
||||||
|
|
||||||
|
except MailServerError as e:
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_mail_server_service = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mail_server_service() -> MailServerService:
|
||||||
|
"""Get the mail server service singleton instance."""
|
||||||
|
global _mail_server_service
|
||||||
|
if _mail_server_service is None:
|
||||||
|
_mail_server_service = MailServerService()
|
||||||
|
return _mail_server_service
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 16:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0004_subscriptionplan'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='limits',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Feature limits and capabilities to grant (e.g. max_users, max_resources)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='permissions',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 16:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0005_subscriptionplan_limits_subscriptionplan_permissions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='is_most_popular',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether to highlight this plan as the most popular choice'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='subscriptionplan',
|
||||||
|
name='show_price',
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to display the price on the marketing site (disable for 'Contact Us')"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 20:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0006_subscriptionplan_is_most_popular_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PlatformEmailAddress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('display_name', models.CharField(help_text="Display name (e.g., 'Support', 'Billing', 'Sales')", max_length=100)),
|
||||||
|
('local_part', models.CharField(help_text='Local part of email address (before @)', max_length=64)),
|
||||||
|
('domain', models.CharField(choices=[('smoothschedule.com', 'smoothschedule.com'), ('talova.net', 'talova.net')], default='smoothschedule.com', help_text='Email domain', max_length=50)),
|
||||||
|
('color', models.CharField(default='#3b82f6', help_text='Hex color code for visual identification', max_length=7)),
|
||||||
|
('password', models.CharField(help_text='Password for the email account (stored encrypted, synced to mail server)', max_length=255)),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this email address is active')),
|
||||||
|
('is_default', models.BooleanField(default=False, help_text='Default email for platform support')),
|
||||||
|
('mail_server_synced', models.BooleanField(default=False, help_text='Whether the account exists on the mail server')),
|
||||||
|
('last_sync_error', models.TextField(blank=True, default='', help_text='Last sync error message, if any')),
|
||||||
|
('last_synced_at', models.DateTimeField(blank=True, help_text='When this account was last synced to the mail server', null=True)),
|
||||||
|
('emails_processed_count', models.IntegerField(default=0, help_text='Total number of emails processed for this address')),
|
||||||
|
('last_check_at', models.DateTimeField(blank=True, help_text='When emails were last checked for this address', null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Platform Email Address',
|
||||||
|
'verbose_name_plural': 'Platform Email Addresses',
|
||||||
|
'ordering': ['-is_default', 'display_name'],
|
||||||
|
'unique_together': {('local_part', 'domain')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 21:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0007_platformemailaddress'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platformemailaddress',
|
||||||
|
name='assigned_user',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='User associated with this email. If set, their name is used as sender name.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='platform_email_addresses', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platformemailaddress',
|
||||||
|
name='sender_name',
|
||||||
|
field=models.CharField(blank=True, default='', help_text="Name to show in From header (e.g., 'SmoothSchedule Support'). If blank, uses display_name.", max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 22:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('platform_admin', '0008_add_sender_name_and_assigned_user'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='platformsettings',
|
||||||
|
name='email_check_interval_minutes',
|
||||||
|
field=models.PositiveIntegerField(default=5, help_text='How often to check for new incoming emails (in minutes)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -74,6 +74,12 @@ class PlatformSettings(models.Model):
|
|||||||
# ...
|
# ...
|
||||||
# }
|
# }
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
email_check_interval_minutes = models.PositiveIntegerField(
|
||||||
|
default=5,
|
||||||
|
help_text="How often to check for new incoming emails (in minutes)"
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -218,6 +224,20 @@ class SubscriptionPlan(models.Model):
|
|||||||
help_text="List of feature descriptions"
|
help_text="List of feature descriptions"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Platform permissions (what features this plan grants)
|
||||||
|
permissions = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Platform permissions to grant (e.g., can_accept_payments, can_use_custom_domain)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Feature limits (what capabilities this plan has)
|
||||||
|
limits = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Feature limits and capabilities to grant (e.g. max_users, max_resources)"
|
||||||
|
)
|
||||||
|
|
||||||
# Transaction fees for payment processing
|
# Transaction fees for payment processing
|
||||||
transaction_fee_percent = models.DecimalField(
|
transaction_fee_percent = models.DecimalField(
|
||||||
max_digits=5,
|
max_digits=5,
|
||||||
@@ -238,6 +258,14 @@ class SubscriptionPlan(models.Model):
|
|||||||
default=True,
|
default=True,
|
||||||
help_text="Whether this plan is visible on public pricing page"
|
help_text="Whether this plan is visible on public pricing page"
|
||||||
)
|
)
|
||||||
|
is_most_popular = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether to highlight this plan as the most popular choice"
|
||||||
|
)
|
||||||
|
show_price = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether to display the price on the marketing site (disable for 'Contact Us')"
|
||||||
|
)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -479,3 +507,169 @@ class TenantInvitation(models.Model):
|
|||||||
limits=limits or {},
|
limits=limits or {},
|
||||||
personal_message=personal_message,
|
personal_message=personal_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddress(models.Model):
|
||||||
|
"""
|
||||||
|
Platform-managed email addresses hosted on mail.talova.net.
|
||||||
|
These are managed directly via SSH/Docker commands on the mail server.
|
||||||
|
|
||||||
|
Unlike TicketEmailAddress which supports arbitrary IMAP/SMTP servers,
|
||||||
|
this model is specifically for platform email addresses managed on our
|
||||||
|
dedicated mail server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Domain(models.TextChoices):
|
||||||
|
SMOOTHSCHEDULE = 'smoothschedule.com', 'smoothschedule.com'
|
||||||
|
TALOVA = 'talova.net', 'talova.net'
|
||||||
|
|
||||||
|
# Display information
|
||||||
|
display_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
|
||||||
|
)
|
||||||
|
local_part = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
help_text="Local part of email address (before @)"
|
||||||
|
)
|
||||||
|
domain = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=Domain.choices,
|
||||||
|
default=Domain.SMOOTHSCHEDULE,
|
||||||
|
help_text="Email domain"
|
||||||
|
)
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
default='#3b82f6',
|
||||||
|
help_text="Hex color code for visual identification"
|
||||||
|
)
|
||||||
|
sender_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Name to show in From header (e.g., 'SmoothSchedule Support'). If blank, uses display_name."
|
||||||
|
)
|
||||||
|
assigned_user = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='platform_email_addresses',
|
||||||
|
help_text="User associated with this email. If set, their name is used as sender name."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Account credentials (stored securely, synced to mail server)
|
||||||
|
password = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="Password for the email account (stored encrypted, synced to mail server)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this email address is active"
|
||||||
|
)
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Default email for platform support"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mail server sync status
|
||||||
|
mail_server_synced = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether the account exists on the mail server"
|
||||||
|
)
|
||||||
|
last_sync_error = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Last sync error message, if any"
|
||||||
|
)
|
||||||
|
last_synced_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When this account was last synced to the mail server"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Usage tracking
|
||||||
|
emails_processed_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Total number of emails processed for this address"
|
||||||
|
)
|
||||||
|
last_check_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When emails were last checked for this address"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
app_label = 'platform_admin'
|
||||||
|
ordering = ['-is_default', 'display_name']
|
||||||
|
unique_together = [['local_part', 'domain']]
|
||||||
|
verbose_name = 'Platform Email Address'
|
||||||
|
verbose_name_plural = 'Platform Email Addresses'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.display_name} <{self.email_address}>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_address(self):
|
||||||
|
"""Full email address."""
|
||||||
|
return f"{self.local_part}@{self.domain}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effective_sender_name(self):
|
||||||
|
"""
|
||||||
|
Name to use in From header.
|
||||||
|
Priority: assigned_user's full name > sender_name > display_name
|
||||||
|
"""
|
||||||
|
if self.assigned_user:
|
||||||
|
user_name = self.assigned_user.get_full_name()
|
||||||
|
if user_name:
|
||||||
|
return user_name
|
||||||
|
if self.sender_name:
|
||||||
|
return self.sender_name
|
||||||
|
return self.display_name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Ensure only one default
|
||||||
|
if self.is_default:
|
||||||
|
PlatformEmailAddress.objects.filter(
|
||||||
|
is_default=True
|
||||||
|
).exclude(pk=self.pk).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# Pre-configured mail server settings
|
||||||
|
MAIL_SERVER_HOST = 'mail.talova.net'
|
||||||
|
IMAP_HOST = 'mail.talova.net'
|
||||||
|
IMAP_PORT = 993
|
||||||
|
IMAP_USE_SSL = True
|
||||||
|
SMTP_HOST = 'mail.talova.net'
|
||||||
|
SMTP_PORT = 587
|
||||||
|
SMTP_USE_TLS = True
|
||||||
|
SMTP_USE_SSL = False
|
||||||
|
|
||||||
|
def get_imap_settings(self):
|
||||||
|
"""Get IMAP connection settings."""
|
||||||
|
return {
|
||||||
|
'host': self.IMAP_HOST,
|
||||||
|
'port': self.IMAP_PORT,
|
||||||
|
'use_ssl': self.IMAP_USE_SSL,
|
||||||
|
'username': self.email_address,
|
||||||
|
'password': self.password,
|
||||||
|
'folder': 'INBOX',
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_smtp_settings(self):
|
||||||
|
"""Get SMTP connection settings."""
|
||||||
|
return {
|
||||||
|
'host': self.SMTP_HOST,
|
||||||
|
'port': self.SMTP_PORT,
|
||||||
|
'use_tls': self.SMTP_USE_TLS,
|
||||||
|
'use_ssl': self.SMTP_USE_SSL,
|
||||||
|
'username': self.email_address,
|
||||||
|
'password': self.password,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Serializers for platform-level operations (viewing tenants, users, metrics)
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from core.models import Tenant, Domain
|
from core.models import Tenant, Domain
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
|
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
|
||||||
|
|
||||||
|
|
||||||
class PlatformSettingsSerializer(serializers.Serializer):
|
class PlatformSettingsSerializer(serializers.Serializer):
|
||||||
@@ -19,6 +19,7 @@ class PlatformSettingsSerializer(serializers.Serializer):
|
|||||||
stripe_validation_error = serializers.CharField(read_only=True)
|
stripe_validation_error = serializers.CharField(read_only=True)
|
||||||
has_stripe_keys = serializers.SerializerMethodField()
|
has_stripe_keys = serializers.SerializerMethodField()
|
||||||
stripe_keys_from_env = serializers.SerializerMethodField()
|
stripe_keys_from_env = serializers.SerializerMethodField()
|
||||||
|
email_check_interval_minutes = serializers.IntegerField(read_only=True)
|
||||||
updated_at = serializers.DateTimeField(read_only=True)
|
updated_at = serializers.DateTimeField(read_only=True)
|
||||||
|
|
||||||
def get_stripe_secret_key_masked(self, obj):
|
def get_stripe_secret_key_masked(self, obj):
|
||||||
@@ -108,8 +109,10 @@ class SubscriptionPlanSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'name', 'description', 'plan_type',
|
'id', 'name', 'description', 'plan_type',
|
||||||
'stripe_product_id', 'stripe_price_id',
|
'stripe_product_id', 'stripe_price_id',
|
||||||
'price_monthly', 'price_yearly', 'business_tier',
|
'price_monthly', 'price_yearly', 'business_tier',
|
||||||
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
|
'features', 'limits', 'permissions',
|
||||||
'is_active', 'is_public', 'created_at', 'updated_at'
|
'transaction_fee_percent', 'transaction_fee_fixed',
|
||||||
|
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
||||||
|
'created_at', 'updated_at'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
@@ -124,8 +127,10 @@ class SubscriptionPlanCreateSerializer(serializers.ModelSerializer):
|
|||||||
'name', 'description', 'plan_type',
|
'name', 'description', 'plan_type',
|
||||||
'stripe_product_id', 'stripe_price_id',
|
'stripe_product_id', 'stripe_price_id',
|
||||||
'price_monthly', 'price_yearly', 'business_tier',
|
'price_monthly', 'price_yearly', 'business_tier',
|
||||||
'features', 'transaction_fee_percent', 'transaction_fee_fixed',
|
'features', 'limits', 'permissions',
|
||||||
'is_active', 'is_public', 'create_stripe_product'
|
'transaction_fee_percent', 'transaction_fee_fixed',
|
||||||
|
'is_active', 'is_public', 'is_most_popular', 'show_price',
|
||||||
|
'create_stripe_product'
|
||||||
]
|
]
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
@@ -579,3 +584,255 @@ class TenantInvitationDetailSerializer(TenantInvitationSerializer):
|
|||||||
'invited_by': {'read_only': True},
|
'invited_by': {'read_only': True},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AssignedUserSerializer(serializers.Serializer):
|
||||||
|
"""Lightweight serializer for assigned user info."""
|
||||||
|
id = serializers.IntegerField(read_only=True)
|
||||||
|
email = serializers.EmailField(read_only=True)
|
||||||
|
first_name = serializers.CharField(read_only=True)
|
||||||
|
last_name = serializers.CharField(read_only=True)
|
||||||
|
full_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_full_name(self, obj):
|
||||||
|
return obj.get_full_name() or obj.email
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddressListSerializer(serializers.ModelSerializer):
|
||||||
|
"""Lightweight serializer for listing platform email addresses."""
|
||||||
|
email_address = serializers.ReadOnlyField()
|
||||||
|
effective_sender_name = serializers.ReadOnlyField()
|
||||||
|
assigned_user = AssignedUserSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PlatformEmailAddress
|
||||||
|
fields = [
|
||||||
|
'id', 'display_name', 'sender_name', 'effective_sender_name',
|
||||||
|
'local_part', 'domain', 'email_address', 'color',
|
||||||
|
'assigned_user',
|
||||||
|
'is_active', 'is_default', 'mail_server_synced',
|
||||||
|
'last_check_at', 'emails_processed_count',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'email_address', 'effective_sender_name', 'mail_server_synced',
|
||||||
|
'last_check_at', 'emails_processed_count',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddressSerializer(serializers.ModelSerializer):
|
||||||
|
"""Full serializer for platform email addresses."""
|
||||||
|
email_address = serializers.ReadOnlyField()
|
||||||
|
effective_sender_name = serializers.ReadOnlyField()
|
||||||
|
assigned_user = AssignedUserSerializer(read_only=True)
|
||||||
|
assigned_user_id = serializers.IntegerField(
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
imap_settings = serializers.SerializerMethodField()
|
||||||
|
smtp_settings = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PlatformEmailAddress
|
||||||
|
fields = [
|
||||||
|
'id', 'display_name', 'sender_name', 'effective_sender_name',
|
||||||
|
'local_part', 'domain', 'email_address', 'color',
|
||||||
|
'assigned_user', 'assigned_user_id',
|
||||||
|
'password', 'is_active', 'is_default',
|
||||||
|
'mail_server_synced', 'last_sync_error', 'last_synced_at',
|
||||||
|
'last_check_at', 'emails_processed_count',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
'imap_settings', 'smtp_settings'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'email_address', 'effective_sender_name',
|
||||||
|
'mail_server_synced', 'last_sync_error', 'last_synced_at',
|
||||||
|
'last_check_at', 'emails_processed_count',
|
||||||
|
'created_at', 'updated_at',
|
||||||
|
'imap_settings', 'smtp_settings'
|
||||||
|
]
|
||||||
|
extra_kwargs = {
|
||||||
|
'password': {'write_only': True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_assigned_user_id(self, value):
|
||||||
|
"""Validate and convert assigned_user_id to User instance."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
try:
|
||||||
|
user = User.objects.get(
|
||||||
|
pk=value,
|
||||||
|
role__in=['superuser', 'platform_manager', 'platform_support'],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("User not found or not a platform user.")
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
assigned_user = validated_data.pop('assigned_user_id', None)
|
||||||
|
instance = super().create(validated_data)
|
||||||
|
if assigned_user is not None:
|
||||||
|
instance.assigned_user = assigned_user
|
||||||
|
instance.save(update_fields=['assigned_user'])
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
if 'assigned_user_id' in validated_data:
|
||||||
|
instance.assigned_user = validated_data.pop('assigned_user_id')
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
def get_imap_settings(self, obj):
|
||||||
|
"""Return IMAP settings without password."""
|
||||||
|
settings = obj.get_imap_settings()
|
||||||
|
settings.pop('password', None)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def get_smtp_settings(self, obj):
|
||||||
|
"""Return SMTP settings without password."""
|
||||||
|
settings = obj.get_smtp_settings()
|
||||||
|
settings.pop('password', None)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def validate_local_part(self, value):
|
||||||
|
"""Validate local part of email address."""
|
||||||
|
import re
|
||||||
|
value = value.lower().strip()
|
||||||
|
|
||||||
|
# Check format
|
||||||
|
if not re.match(r'^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$', value):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Local part must start and end with a letter or number, "
|
||||||
|
"and can only contain letters, numbers, dots, underscores, and hyphens"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(value) > 64:
|
||||||
|
raise serializers.ValidationError("Local part cannot exceed 64 characters")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
"""Validate password strength."""
|
||||||
|
if len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Password must be at least 8 characters")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Cross-field validation."""
|
||||||
|
local_part = attrs.get('local_part', getattr(self.instance, 'local_part', None))
|
||||||
|
domain = attrs.get('domain', getattr(self.instance, 'domain', None))
|
||||||
|
|
||||||
|
if local_part and domain:
|
||||||
|
# Check uniqueness
|
||||||
|
qs = PlatformEmailAddress.objects.filter(
|
||||||
|
local_part=local_part.lower(),
|
||||||
|
domain=domain
|
||||||
|
)
|
||||||
|
if self.instance:
|
||||||
|
qs = qs.exclude(pk=self.instance.pk)
|
||||||
|
if qs.exists():
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'local_part': f'Email address {local_part}@{domain} already exists'
|
||||||
|
})
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddressCreateSerializer(PlatformEmailAddressSerializer):
|
||||||
|
"""Serializer for creating platform email addresses with mail server sync."""
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Create the email address and sync to mail server."""
|
||||||
|
from .mail_server import get_mail_server_service
|
||||||
|
|
||||||
|
# Normalize local_part
|
||||||
|
validated_data['local_part'] = validated_data['local_part'].lower()
|
||||||
|
|
||||||
|
# Create the database record first
|
||||||
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
# Sync to mail server
|
||||||
|
service = get_mail_server_service()
|
||||||
|
success, message = service.sync_account(instance)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# Delete the database record if mail server sync failed
|
||||||
|
instance.delete()
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'mail_server': f'Failed to create email account on mail server: {message}'
|
||||||
|
})
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddressUpdateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for updating platform email addresses."""
|
||||||
|
email_address = serializers.ReadOnlyField()
|
||||||
|
assigned_user_id = serializers.IntegerField(
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PlatformEmailAddress
|
||||||
|
fields = [
|
||||||
|
'id', 'display_name', 'sender_name', 'email_address', 'color',
|
||||||
|
'assigned_user_id',
|
||||||
|
'password', 'is_active', 'is_default'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'email_address']
|
||||||
|
extra_kwargs = {
|
||||||
|
'password': {'write_only': True, 'required': False},
|
||||||
|
'sender_name': {'required': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate_assigned_user_id(self, value):
|
||||||
|
"""Validate and convert assigned_user_id to User instance."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
try:
|
||||||
|
user = User.objects.get(
|
||||||
|
pk=value,
|
||||||
|
role__in=['superuser', 'platform_manager', 'platform_support'],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
except User.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("User not found or not a platform user.")
|
||||||
|
|
||||||
|
def validate_password(self, value):
|
||||||
|
"""Validate password strength if provided."""
|
||||||
|
if value and len(value) < 8:
|
||||||
|
raise serializers.ValidationError("Password must be at least 8 characters")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Update the email address and sync password to mail server if changed."""
|
||||||
|
from .mail_server import get_mail_server_service
|
||||||
|
|
||||||
|
password = validated_data.get('password')
|
||||||
|
password_changed = password and password != instance.password
|
||||||
|
|
||||||
|
# Handle assigned_user_id separately
|
||||||
|
if 'assigned_user_id' in validated_data:
|
||||||
|
instance.assigned_user = validated_data.pop('assigned_user_id')
|
||||||
|
|
||||||
|
# Update the instance
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
# Sync to mail server if password changed
|
||||||
|
if password_changed:
|
||||||
|
service = get_mail_server_service()
|
||||||
|
success, message = service.sync_account(instance)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'mail_server': f'Failed to update password on mail server: {message}'
|
||||||
|
})
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ from .views import (
|
|||||||
TenantInvitationViewSet,
|
TenantInvitationViewSet,
|
||||||
SubscriptionPlanViewSet,
|
SubscriptionPlanViewSet,
|
||||||
PlatformSettingsView,
|
PlatformSettingsView,
|
||||||
|
GeneralSettingsView,
|
||||||
StripeKeysView,
|
StripeKeysView,
|
||||||
StripeValidateView,
|
StripeValidateView,
|
||||||
StripeWebhooksView,
|
StripeWebhooksView,
|
||||||
StripeWebhookDetailView,
|
StripeWebhookDetailView,
|
||||||
StripeWebhookRotateSecretView,
|
StripeWebhookRotateSecretView,
|
||||||
OAuthSettingsView,
|
OAuthSettingsView,
|
||||||
|
PlatformEmailAddressViewSet,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'platform'
|
app_name = 'platform'
|
||||||
@@ -24,12 +26,14 @@ router.register(r'businesses', TenantViewSet, basename='business')
|
|||||||
router.register(r'users', PlatformUserViewSet, basename='user')
|
router.register(r'users', PlatformUserViewSet, basename='user')
|
||||||
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
|
router.register(r'tenant-invitations', TenantInvitationViewSet, basename='tenant-invitation')
|
||||||
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
|
router.register(r'subscription-plans', SubscriptionPlanViewSet, basename='subscription-plan')
|
||||||
|
router.register(r'email-addresses', PlatformEmailAddressViewSet, basename='email-address')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
|
|
||||||
# Platform settings endpoints
|
# Platform settings endpoints
|
||||||
path('settings/', PlatformSettingsView.as_view(), name='settings'),
|
path('settings/', PlatformSettingsView.as_view(), name='settings'),
|
||||||
|
path('settings/general/', GeneralSettingsView.as_view(), name='general-settings'),
|
||||||
path('settings/stripe/keys/', StripeKeysView.as_view(), name='stripe-keys'),
|
path('settings/stripe/keys/', StripeKeysView.as_view(), name='stripe-keys'),
|
||||||
path('settings/stripe/validate/', StripeValidateView.as_view(), name='stripe-validate'),
|
path('settings/stripe/validate/', StripeValidateView.as_view(), name='stripe-validate'),
|
||||||
path('settings/oauth/', OAuthSettingsView.as_view(), name='oauth-settings'),
|
path('settings/oauth/', OAuthSettingsView.as_view(), name='oauth-settings'),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from django_tenants.utils import schema_context
|
|||||||
|
|
||||||
from core.models import Tenant, Domain
|
from core.models import Tenant, Domain
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan
|
from .models import TenantInvitation, PlatformSettings, SubscriptionPlan, PlatformEmailAddress
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TenantSerializer,
|
TenantSerializer,
|
||||||
TenantCreateSerializer,
|
TenantCreateSerializer,
|
||||||
@@ -34,6 +34,10 @@ from .serializers import (
|
|||||||
OAuthSettingsResponseSerializer,
|
OAuthSettingsResponseSerializer,
|
||||||
SubscriptionPlanSerializer,
|
SubscriptionPlanSerializer,
|
||||||
SubscriptionPlanCreateSerializer,
|
SubscriptionPlanCreateSerializer,
|
||||||
|
PlatformEmailAddressListSerializer,
|
||||||
|
PlatformEmailAddressSerializer,
|
||||||
|
PlatformEmailAddressCreateSerializer,
|
||||||
|
PlatformEmailAddressUpdateSerializer,
|
||||||
)
|
)
|
||||||
from .permissions import IsPlatformAdmin, IsPlatformUser
|
from .permissions import IsPlatformAdmin, IsPlatformUser
|
||||||
|
|
||||||
@@ -139,6 +143,42 @@ class StripeValidateView(APIView):
|
|||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
class GeneralSettingsView(APIView):
|
||||||
|
"""
|
||||||
|
POST /api/platform/settings/general/
|
||||||
|
Update general platform settings (email check interval, etc.)
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
settings = PlatformSettings.get_instance()
|
||||||
|
|
||||||
|
# Update email check interval if provided
|
||||||
|
email_check_interval = request.data.get('email_check_interval_minutes')
|
||||||
|
if email_check_interval is not None:
|
||||||
|
try:
|
||||||
|
interval = int(email_check_interval)
|
||||||
|
if interval < 1:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Email check interval must be at least 1 minute'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
if interval > 60:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Email check interval cannot exceed 60 minutes'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
settings.email_check_interval_minutes = interval
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Invalid email check interval'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.save()
|
||||||
|
return Response(PlatformSettingsSerializer(settings).data)
|
||||||
|
|
||||||
|
|
||||||
class OAuthSettingsView(APIView):
|
class OAuthSettingsView(APIView):
|
||||||
"""
|
"""
|
||||||
GET/POST /api/platform/settings/oauth/
|
GET/POST /api/platform/settings/oauth/
|
||||||
@@ -1007,3 +1047,328 @@ class TenantInvitationViewSet(viewsets.ModelViewSet):
|
|||||||
invitation.accept(tenant, owner_user)
|
invitation.accept(tenant, owner_user)
|
||||||
|
|
||||||
return Response({"detail": "Invitation accepted, tenant and user created."}, status=status.HTTP_201_CREATED)
|
return Response({"detail": "Invitation accepted, tenant and user created."}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailAddressViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing platform email addresses.
|
||||||
|
These are email addresses hosted on mail.talova.net that are
|
||||||
|
managed directly via SSH commands to the mail server.
|
||||||
|
|
||||||
|
Platform admins only.
|
||||||
|
"""
|
||||||
|
queryset = PlatformEmailAddress.objects.all().order_by('-is_default', 'display_name')
|
||||||
|
permission_classes = [IsAuthenticated, IsPlatformAdmin]
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'list':
|
||||||
|
return PlatformEmailAddressListSerializer
|
||||||
|
if self.action == 'create':
|
||||||
|
return PlatformEmailAddressCreateSerializer
|
||||||
|
if self.action in ['update', 'partial_update']:
|
||||||
|
return PlatformEmailAddressUpdateSerializer
|
||||||
|
return PlatformEmailAddressSerializer
|
||||||
|
|
||||||
|
def perform_destroy(self, instance):
|
||||||
|
"""Delete email address from both database and mail server."""
|
||||||
|
from .mail_server import get_mail_server_service
|
||||||
|
|
||||||
|
# Delete from mail server first
|
||||||
|
service = get_mail_server_service()
|
||||||
|
success, message = service.delete_and_unsync(instance)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# Log the error but still delete from database
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"Failed to delete email from mail server: {message}")
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
instance.delete()
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def sync(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Manually sync this email address to the mail server.
|
||||||
|
Creates the account if it doesn't exist, or updates the password if it does.
|
||||||
|
"""
|
||||||
|
from .mail_server import get_mail_server_service
|
||||||
|
|
||||||
|
email_address = self.get_object()
|
||||||
|
service = get_mail_server_service()
|
||||||
|
|
||||||
|
success, message = service.sync_account(email_address)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': message,
|
||||||
|
'mail_server_synced': email_address.mail_server_synced,
|
||||||
|
'last_synced_at': email_address.last_synced_at,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': message,
|
||||||
|
'mail_server_synced': email_address.mail_server_synced,
|
||||||
|
'last_sync_error': email_address.last_sync_error,
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def set_as_default(self, request, pk=None):
|
||||||
|
"""Set this email address as the default for platform support."""
|
||||||
|
email_address = self.get_object()
|
||||||
|
|
||||||
|
# Unset all other defaults
|
||||||
|
PlatformEmailAddress.objects.filter(
|
||||||
|
is_default=True
|
||||||
|
).exclude(pk=email_address.pk).update(is_default=False)
|
||||||
|
|
||||||
|
# Set this one as default
|
||||||
|
email_address.is_default = True
|
||||||
|
email_address.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'{email_address.display_name} is now the default email address',
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_imap(self, request, pk=None):
|
||||||
|
"""Test IMAP connection for this email address."""
|
||||||
|
import imaplib
|
||||||
|
|
||||||
|
email_address = self.get_object()
|
||||||
|
settings = email_address.get_imap_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings['use_ssl']:
|
||||||
|
imap = imaplib.IMAP4_SSL(settings['host'], settings['port'])
|
||||||
|
else:
|
||||||
|
imap = imaplib.IMAP4(settings['host'], settings['port'])
|
||||||
|
|
||||||
|
imap.login(settings['username'], settings['password'])
|
||||||
|
imap.select(settings['folder'])
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'IMAP connection successful',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'IMAP connection failed: {str(e)}',
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_smtp(self, request, pk=None):
|
||||||
|
"""Test SMTP connection for this email address."""
|
||||||
|
import smtplib
|
||||||
|
|
||||||
|
email_address = self.get_object()
|
||||||
|
settings = email_address.get_smtp_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings['use_ssl']:
|
||||||
|
smtp = smtplib.SMTP_SSL(settings['host'], settings['port'])
|
||||||
|
else:
|
||||||
|
smtp = smtplib.SMTP(settings['host'], settings['port'])
|
||||||
|
if settings['use_tls']:
|
||||||
|
smtp.starttls()
|
||||||
|
|
||||||
|
smtp.login(settings['username'], settings['password'])
|
||||||
|
smtp.quit()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'SMTP connection successful',
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'SMTP connection failed: {str(e)}',
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def test_mail_server(self, request):
|
||||||
|
"""Test SSH connection to the mail server."""
|
||||||
|
from .mail_server import get_mail_server_service
|
||||||
|
|
||||||
|
service = get_mail_server_service()
|
||||||
|
success, message = service.test_connection()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': message,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': message,
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def mail_server_accounts(self, request):
|
||||||
|
"""List all email accounts on the mail server."""
|
||||||
|
from .mail_server import get_mail_server_service, MailServerError
|
||||||
|
|
||||||
|
service = get_mail_server_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
accounts = service.list_accounts()
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'accounts': accounts,
|
||||||
|
'count': len(accounts),
|
||||||
|
})
|
||||||
|
except MailServerError as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': str(e),
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def available_domains(self, request):
|
||||||
|
"""Get available email domains."""
|
||||||
|
domains = [
|
||||||
|
{'value': choice[0], 'label': choice[1]}
|
||||||
|
for choice in PlatformEmailAddress.Domain.choices
|
||||||
|
]
|
||||||
|
return Response({
|
||||||
|
'domains': domains,
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def assignable_users(self, request):
|
||||||
|
"""Get users that can be assigned to email addresses."""
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
users = User.objects.filter(
|
||||||
|
role__in=['superuser', 'platform_manager', 'platform_support'],
|
||||||
|
is_active=True
|
||||||
|
).order_by('first_name', 'last_name', 'email')
|
||||||
|
|
||||||
|
user_list = [
|
||||||
|
{
|
||||||
|
'id': user.id,
|
||||||
|
'email': user.email,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'full_name': user.get_full_name() or user.email,
|
||||||
|
'role': user.role,
|
||||||
|
}
|
||||||
|
for user in users
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'users': user_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def remove_local(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Remove email address from database only, without deleting from mail server.
|
||||||
|
Useful for removing an address from the platform while keeping
|
||||||
|
the mail server account intact.
|
||||||
|
"""
|
||||||
|
email_address = self.get_object()
|
||||||
|
email = email_address.email_address
|
||||||
|
display_name = email_address.display_name
|
||||||
|
|
||||||
|
# Just delete from database, don't touch mail server
|
||||||
|
email_address.delete()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Removed {display_name} ({email}) from database. Account still exists on mail server.',
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def import_from_mail_server(self, request):
|
||||||
|
"""
|
||||||
|
Import existing email accounts from the mail server.
|
||||||
|
Only imports accounts with supported domains that don't already exist in the database.
|
||||||
|
"""
|
||||||
|
from .mail_server import get_mail_server_service, MailServerError
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
service = get_mail_server_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
accounts = service.list_accounts()
|
||||||
|
except MailServerError as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': str(e),
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Only import smoothschedule.com addresses
|
||||||
|
supported_domains = ['smoothschedule.com']
|
||||||
|
|
||||||
|
# Get existing email addresses (construct from local_part + domain)
|
||||||
|
existing_emails = set(
|
||||||
|
f"{addr.local_part}@{addr.domain}".lower()
|
||||||
|
for addr in PlatformEmailAddress.objects.only('local_part', 'domain')
|
||||||
|
)
|
||||||
|
|
||||||
|
imported = []
|
||||||
|
skipped = []
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
email = account.get('email', '')
|
||||||
|
if not email or '@' not in email:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_part, domain = email.rsplit('@', 1)
|
||||||
|
|
||||||
|
# Skip if domain not supported
|
||||||
|
if domain not in supported_domains:
|
||||||
|
skipped.append({
|
||||||
|
'email': email,
|
||||||
|
'reason': 'Unsupported domain',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip if already exists
|
||||||
|
if email.lower() in existing_emails:
|
||||||
|
skipped.append({
|
||||||
|
'email': email,
|
||||||
|
'reason': 'Already exists in database',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create the email address with a placeholder password
|
||||||
|
# User will need to update the password to sync properly
|
||||||
|
placeholder_password = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_address = PlatformEmailAddress.objects.create(
|
||||||
|
display_name=local_part.title().replace('.', ' ').replace('-', ' '),
|
||||||
|
local_part=local_part,
|
||||||
|
domain=domain,
|
||||||
|
password=placeholder_password,
|
||||||
|
is_active=True,
|
||||||
|
is_default=False,
|
||||||
|
mail_server_synced=True, # Already exists on server
|
||||||
|
)
|
||||||
|
imported.append({
|
||||||
|
'id': email_address.id,
|
||||||
|
'email': email_address.email_address,
|
||||||
|
'display_name': email_address.display_name,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
skipped.append({
|
||||||
|
'email': email,
|
||||||
|
'reason': str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'imported': imported,
|
||||||
|
'imported_count': len(imported),
|
||||||
|
'skipped': skipped,
|
||||||
|
'skipped_count': len(skipped),
|
||||||
|
'message': f'Imported {len(imported)} email addresses, skipped {len(skipped)}',
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
|
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailAddress
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Ticket)
|
@admin.register(Ticket)
|
||||||
@@ -91,3 +91,38 @@ class CannedResponseAdmin(admin.ModelAdmin):
|
|||||||
'classes': ('collapse',)
|
'classes': ('collapse',)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TicketEmailAddress)
|
||||||
|
class TicketEmailAddressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'display_name', 'email_address', 'tenant', 'is_active', 'is_default', 'color', 'emails_processed_count', 'last_check_at')
|
||||||
|
list_filter = ('is_active', 'is_default', 'tenant', 'created_at')
|
||||||
|
search_fields = ('display_name', 'email_address', 'tenant__name')
|
||||||
|
readonly_fields = ('last_check_at', 'last_error', 'emails_processed_count', 'created_at', 'updated_at')
|
||||||
|
raw_id_fields = ('tenant',)
|
||||||
|
ordering = ('tenant', '-is_default', 'display_name')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('tenant', 'display_name', 'email_address', 'color')
|
||||||
|
}),
|
||||||
|
('IMAP Settings (Inbound)', {
|
||||||
|
'fields': ('imap_host', 'imap_port', 'imap_use_ssl', 'imap_username', 'imap_password', 'imap_folder'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('SMTP Settings (Outbound)', {
|
||||||
|
'fields': ('smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username', 'smtp_password'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Status & Settings', {
|
||||||
|
'fields': ('is_active', 'is_default')
|
||||||
|
}),
|
||||||
|
('Statistics', {
|
||||||
|
'fields': ('last_check_at', 'last_error', 'emails_processed_count'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ Uses email templates from the EmailTemplate model with ticket-specific context v
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
@@ -22,6 +25,23 @@ from .models import Ticket, TicketComment
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_platform_email():
|
||||||
|
"""
|
||||||
|
Get the default PlatformEmailAddress for sending platform emails.
|
||||||
|
Returns None if no default is configured.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from platform_admin.models import PlatformEmailAddress
|
||||||
|
return PlatformEmailAddress.objects.filter(
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
mail_server_synced=True
|
||||||
|
).first()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get default platform email: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailService:
|
class TicketEmailService:
|
||||||
"""
|
"""
|
||||||
Service for sending ticket-related email notifications.
|
Service for sending ticket-related email notifications.
|
||||||
@@ -139,6 +159,9 @@ class TicketEmailService:
|
|||||||
"""
|
"""
|
||||||
Send an email with both HTML and plain text versions.
|
Send an email with both HTML and plain text versions.
|
||||||
|
|
||||||
|
For platform-level tickets (no tenant), uses the default PlatformEmailAddress
|
||||||
|
with direct SMTP. For tenant tickets, uses Django's email backend.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
to_email: Recipient email address
|
to_email: Recipient email address
|
||||||
subject: Email subject
|
subject: Email subject
|
||||||
@@ -153,10 +176,39 @@ class TicketEmailService:
|
|||||||
logger.warning("Cannot send email: no recipient address")
|
logger.warning("Cannot send email: no recipient address")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Generate reply-to address
|
||||||
|
if not reply_to:
|
||||||
|
reply_domain = None
|
||||||
|
try:
|
||||||
|
from .models import TicketEmailSettings
|
||||||
|
email_settings = TicketEmailSettings.get_instance()
|
||||||
|
if email_settings.support_email_domain:
|
||||||
|
reply_domain = email_settings.support_email_domain
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not reply_domain:
|
||||||
|
reply_domain = getattr(settings, 'SUPPORT_EMAIL_DOMAIN', 'smoothschedule.com')
|
||||||
|
|
||||||
|
reply_to = f"support+ticket-{self.ticket.id}@{reply_domain}"
|
||||||
|
|
||||||
|
# For platform-level tickets, try to use the default PlatformEmailAddress
|
||||||
|
if not self.tenant:
|
||||||
|
platform_email = get_default_platform_email()
|
||||||
|
if platform_email:
|
||||||
|
return self._send_via_platform_smtp(
|
||||||
|
platform_email=platform_email,
|
||||||
|
to_email=to_email,
|
||||||
|
subject=subject,
|
||||||
|
html_content=html_content,
|
||||||
|
text_content=text_content,
|
||||||
|
reply_to=reply_to
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fall back to Django's email backend
|
||||||
try:
|
try:
|
||||||
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@smoothschedule.com')
|
||||||
|
|
||||||
# Create email message
|
|
||||||
msg = EmailMultiAlternatives(
|
msg = EmailMultiAlternatives(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=text_content,
|
body=text_content,
|
||||||
@@ -164,32 +216,10 @@ class TicketEmailService:
|
|||||||
to=[to_email],
|
to=[to_email],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add HTML version
|
|
||||||
if html_content:
|
if html_content:
|
||||||
msg.attach_alternative(html_content, 'text/html')
|
msg.attach_alternative(html_content, 'text/html')
|
||||||
|
|
||||||
# Add Reply-To header with ticket ID for inbound processing
|
msg.reply_to = [reply_to]
|
||||||
if reply_to:
|
|
||||||
msg.reply_to = [reply_to]
|
|
||||||
else:
|
|
||||||
# Generate reply-to with ticket ID for threading
|
|
||||||
# Try to get domain from TicketEmailSettings first, then fall back to settings
|
|
||||||
reply_domain = None
|
|
||||||
try:
|
|
||||||
from .models import TicketEmailSettings
|
|
||||||
email_settings = TicketEmailSettings.get_instance()
|
|
||||||
if email_settings.support_email_domain:
|
|
||||||
reply_domain = email_settings.support_email_domain
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not reply_domain:
|
|
||||||
reply_domain = getattr(settings, 'SUPPORT_EMAIL_DOMAIN', 'smoothschedule.com')
|
|
||||||
|
|
||||||
# Format: support+ticket-{id}@domain.com
|
|
||||||
msg.reply_to = [f"support+ticket-{self.ticket.id}@{reply_domain}"]
|
|
||||||
|
|
||||||
# Add headers for email threading
|
|
||||||
msg.extra_headers = {
|
msg.extra_headers = {
|
||||||
'X-Ticket-ID': str(self.ticket.id),
|
'X-Ticket-ID': str(self.ticket.id),
|
||||||
'X-Ticket-Type': self.ticket.ticket_type,
|
'X-Ticket-Type': self.ticket.ticket_type,
|
||||||
@@ -203,6 +233,68 @@ class TicketEmailService:
|
|||||||
logger.error(f"Failed to send ticket email to {to_email}: {e}")
|
logger.error(f"Failed to send ticket email to {to_email}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _send_via_platform_smtp(
|
||||||
|
self,
|
||||||
|
platform_email,
|
||||||
|
to_email: str,
|
||||||
|
subject: str,
|
||||||
|
html_content: str,
|
||||||
|
text_content: str,
|
||||||
|
reply_to: str
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send email using the PlatformEmailAddress SMTP settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
platform_email: PlatformEmailAddress instance
|
||||||
|
to_email: Recipient email address
|
||||||
|
subject: Email subject
|
||||||
|
html_content: HTML email body
|
||||||
|
text_content: Plain text email body
|
||||||
|
reply_to: Reply-To address
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if email sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
smtp_settings = platform_email.get_smtp_settings()
|
||||||
|
|
||||||
|
# Build the email
|
||||||
|
if html_content:
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg.attach(MIMEText(text_content, 'plain'))
|
||||||
|
msg.attach(MIMEText(html_content, 'html'))
|
||||||
|
else:
|
||||||
|
msg = MIMEText(text_content, 'plain')
|
||||||
|
|
||||||
|
# Set headers
|
||||||
|
from_addr = f"{platform_email.effective_sender_name} <{platform_email.email_address}>"
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = from_addr
|
||||||
|
msg['To'] = to_email
|
||||||
|
msg['Reply-To'] = reply_to
|
||||||
|
msg['X-Ticket-ID'] = str(self.ticket.id)
|
||||||
|
msg['X-Ticket-Type'] = self.ticket.ticket_type
|
||||||
|
|
||||||
|
# Connect and send
|
||||||
|
if smtp_settings['use_ssl']:
|
||||||
|
server = smtplib.SMTP_SSL(smtp_settings['host'], smtp_settings['port'])
|
||||||
|
else:
|
||||||
|
server = smtplib.SMTP(smtp_settings['host'], smtp_settings['port'])
|
||||||
|
if smtp_settings['use_tls']:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
server.login(smtp_settings['username'], smtp_settings['password'])
|
||||||
|
server.sendmail(platform_email.email_address, [to_email], msg.as_string())
|
||||||
|
server.quit()
|
||||||
|
|
||||||
|
logger.info(f"Sent platform ticket email from {platform_email.email_address} to {to_email}: {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send platform email via SMTP to {to_email}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def send_assignment_notification(self) -> bool:
|
def send_assignment_notification(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Send notification when ticket is assigned to someone.
|
Send notification when ticket is assigned to someone.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Inbound Email Receiver Service
|
|||||||
Processes incoming emails and creates ticket comments from replies.
|
Processes incoming emails and creates ticket comments from replies.
|
||||||
Supports:
|
Supports:
|
||||||
- IMAP polling for new emails
|
- IMAP polling for new emails
|
||||||
|
- Multiple email addresses (platform-wide and business-specific)
|
||||||
- Ticket ID extraction from reply-to addresses and subject lines
|
- Ticket ID extraction from reply-to addresses and subject lines
|
||||||
- Reply text extraction (stripping quoted content)
|
- Reply text extraction (stripping quoted content)
|
||||||
- User matching by email address
|
- User matching by email address
|
||||||
@@ -11,8 +12,10 @@ Supports:
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from tickets.email_receiver import TicketEmailReceiver
|
from tickets.email_receiver import TicketEmailReceiver
|
||||||
|
from tickets.models import TicketEmailAddress
|
||||||
|
|
||||||
receiver = TicketEmailReceiver()
|
email_address = TicketEmailAddress.objects.get(id=1)
|
||||||
|
receiver = TicketEmailReceiver(email_address)
|
||||||
processed_count = receiver.fetch_and_process_emails()
|
processed_count = receiver.fetch_and_process_emails()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ from django.db import transaction
|
|||||||
from .models import (
|
from .models import (
|
||||||
Ticket,
|
Ticket,
|
||||||
TicketComment,
|
TicketComment,
|
||||||
TicketEmailSettings,
|
TicketEmailAddress,
|
||||||
IncomingTicketEmail
|
IncomingTicketEmail
|
||||||
)
|
)
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
@@ -54,14 +57,16 @@ class TicketEmailReceiver:
|
|||||||
r'#(\d+)', # #123 in subject (less specific)
|
r'#(\d+)', # #123 in subject (less specific)
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, email_address: TicketEmailAddress):
|
||||||
"""Initialize the email receiver with settings from database."""
|
"""Initialize the email receiver with a specific email address configuration."""
|
||||||
self.settings = TicketEmailSettings.get_instance()
|
self.email_address = email_address
|
||||||
self.connection = None
|
self.connection = None
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""Check if email receiving is properly configured."""
|
"""Check if email receiving is properly configured."""
|
||||||
return self.settings.is_configured() and self.settings.is_enabled
|
return (self.email_address.is_imap_configured and
|
||||||
|
self.email_address.is_smtp_configured and
|
||||||
|
self.email_address.is_active)
|
||||||
|
|
||||||
def connect(self) -> bool:
|
def connect(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -70,37 +75,37 @@ class TicketEmailReceiver:
|
|||||||
Returns:
|
Returns:
|
||||||
True if connection successful, False otherwise
|
True if connection successful, False otherwise
|
||||||
"""
|
"""
|
||||||
if not self.settings.is_configured():
|
if not self.email_address.is_imap_configured:
|
||||||
logger.error("IMAP settings not configured")
|
logger.error(f"[{self.email_address.display_name}] IMAP settings not configured")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self.settings.imap_use_ssl:
|
if self.email_address.imap_use_ssl:
|
||||||
self.connection = imaplib.IMAP4_SSL(
|
self.connection = imaplib.IMAP4_SSL(
|
||||||
self.settings.imap_host,
|
self.email_address.imap_host,
|
||||||
self.settings.imap_port
|
self.email_address.imap_port
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.connection = imaplib.IMAP4(
|
self.connection = imaplib.IMAP4(
|
||||||
self.settings.imap_host,
|
self.email_address.imap_host,
|
||||||
self.settings.imap_port
|
self.email_address.imap_port
|
||||||
)
|
)
|
||||||
|
|
||||||
self.connection.login(
|
self.connection.login(
|
||||||
self.settings.imap_username,
|
self.email_address.imap_username,
|
||||||
self.settings.imap_password
|
self.email_address.imap_password
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Connected to IMAP server {self.settings.imap_host}")
|
logger.info(f"[{self.email_address.display_name}] Connected to IMAP server {self.email_address.imap_host}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except imaplib.IMAP4.error as e:
|
except imaplib.IMAP4.error as e:
|
||||||
logger.error(f"IMAP login failed: {e}")
|
logger.error(f"[{self.email_address.display_name}] IMAP login failed: {e}")
|
||||||
self._update_settings_error(f"IMAP login failed: {e}")
|
self._update_email_address_error(f"IMAP login failed: {e}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to IMAP server: {e}")
|
logger.error(f"[{self.email_address.display_name}] Failed to connect to IMAP server: {e}")
|
||||||
self._update_settings_error(f"Connection failed: {e}")
|
self._update_email_address_error(f"Connection failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def disconnect(self):
|
def disconnect(self):
|
||||||
@@ -130,37 +135,34 @@ class TicketEmailReceiver:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Select the inbox folder
|
# Select the inbox folder
|
||||||
self.connection.select(self.settings.imap_folder)
|
self.connection.select(self.email_address.imap_folder)
|
||||||
|
|
||||||
# Search for unread emails
|
# Search for unread emails
|
||||||
status, messages = self.connection.search(None, 'UNSEEN')
|
status, messages = self.connection.search(None, 'UNSEEN')
|
||||||
|
|
||||||
if status != 'OK':
|
if status != 'OK':
|
||||||
logger.error(f"Failed to search emails: {status}")
|
logger.error(f"[{self.email_address.display_name}] Failed to search emails: {status}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
email_ids = messages[0].split()
|
email_ids = messages[0].split()
|
||||||
logger.info(f"Found {len(email_ids)} unread emails")
|
logger.info(f"[{self.email_address.display_name}] Found {len(email_ids)} unread emails")
|
||||||
|
|
||||||
for email_id in email_ids:
|
for email_id in email_ids:
|
||||||
try:
|
try:
|
||||||
if self._process_single_email(email_id):
|
if self._process_single_email(email_id):
|
||||||
processed_count += 1
|
processed_count += 1
|
||||||
# Delete email from server if configured
|
|
||||||
if self.settings.delete_after_processing:
|
|
||||||
self._delete_email(email_id)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing email {email_id}: {e}")
|
logger.error(f"[{self.email_address.display_name}] Error processing email {email_id}: {e}")
|
||||||
|
|
||||||
# Update settings with last check time
|
# Update email address with last check time
|
||||||
self.settings.last_check_at = timezone.now()
|
self.email_address.last_check_at = timezone.now()
|
||||||
self.settings.last_error = ''
|
self.email_address.last_error = ''
|
||||||
self.settings.emails_processed_count += processed_count
|
self.email_address.emails_processed_count += processed_count
|
||||||
self.settings.save()
|
self.email_address.save()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching emails: {e}")
|
logger.error(f"[{self.email_address.display_name}] Error fetching emails: {e}")
|
||||||
self._update_settings_error(str(e))
|
self._update_email_address_error(str(e))
|
||||||
finally:
|
finally:
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
|
||||||
@@ -665,11 +667,11 @@ class TicketEmailReceiver:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _update_settings_error(self, error: str):
|
def _update_email_address_error(self, error: str):
|
||||||
"""Update settings with error message."""
|
"""Update email address with error message."""
|
||||||
self.settings.last_error = error
|
self.email_address.last_error = error
|
||||||
self.settings.last_check_at = timezone.now()
|
self.email_address.last_check_at = timezone.now()
|
||||||
self.settings.save()
|
self.email_address.save()
|
||||||
|
|
||||||
def _delete_email(self, email_id: bytes):
|
def _delete_email(self, email_id: bytes):
|
||||||
"""
|
"""
|
||||||
@@ -688,6 +690,437 @@ class TicketEmailReceiver:
|
|||||||
logger.error(f"Failed to delete email {email_id}: {e}")
|
logger.error(f"Failed to delete email {email_id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformEmailReceiver:
|
||||||
|
"""
|
||||||
|
Service for receiving and processing inbound ticket emails from PlatformEmailAddress.
|
||||||
|
Similar to TicketEmailReceiver but adapted for platform-managed email addresses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Patterns to extract ticket ID from email addresses
|
||||||
|
TICKET_ID_PATTERNS = [
|
||||||
|
r'ticket[_-](\d+)', # ticket-123 or ticket_123
|
||||||
|
r'\+ticket[_-](\d+)', # +ticket-123 (subaddressing)
|
||||||
|
r'reply[_-](\d+)', # reply-123
|
||||||
|
r'\[Ticket #(\d+)\]', # [Ticket #123] in subject
|
||||||
|
r'#(\d+)', # #123 in subject (less specific)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, email_address):
|
||||||
|
"""Initialize with a PlatformEmailAddress instance."""
|
||||||
|
from platform_admin.models import PlatformEmailAddress
|
||||||
|
self.email_address = email_address
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
def connect(self) -> bool:
|
||||||
|
"""Establish connection to IMAP server."""
|
||||||
|
imap_settings = self.email_address.get_imap_settings()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if imap_settings['use_ssl']:
|
||||||
|
self.connection = imaplib.IMAP4_SSL(
|
||||||
|
imap_settings['host'],
|
||||||
|
imap_settings['port']
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.connection = imaplib.IMAP4(
|
||||||
|
imap_settings['host'],
|
||||||
|
imap_settings['port']
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connection.login(
|
||||||
|
imap_settings['username'],
|
||||||
|
imap_settings['password']
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[Platform: {self.email_address.display_name}] Connected to IMAP server")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except imaplib.IMAP4.error as e:
|
||||||
|
logger.error(f"[Platform: {self.email_address.display_name}] IMAP login failed: {e}")
|
||||||
|
self._update_error(f"IMAP login failed: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Platform: {self.email_address.display_name}] Failed to connect: {e}")
|
||||||
|
self._update_error(f"Connection failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Close IMAP connection."""
|
||||||
|
if self.connection:
|
||||||
|
try:
|
||||||
|
self.connection.logout()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.connection = None
|
||||||
|
|
||||||
|
def fetch_and_process_emails(self) -> int:
|
||||||
|
"""Fetch new emails and process them into tickets."""
|
||||||
|
if not self.email_address.is_active:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not self.connect():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.connection.select('INBOX')
|
||||||
|
status, messages = self.connection.search(None, 'UNSEEN')
|
||||||
|
|
||||||
|
if status != 'OK':
|
||||||
|
logger.error(f"[Platform: {self.email_address.display_name}] Failed to search emails")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
email_ids = messages[0].split()
|
||||||
|
logger.info(f"[Platform: {self.email_address.display_name}] Found {len(email_ids)} unread emails")
|
||||||
|
|
||||||
|
for email_id in email_ids:
|
||||||
|
try:
|
||||||
|
if self._process_single_email(email_id):
|
||||||
|
processed_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Platform: {self.email_address.display_name}] Error processing email {email_id}: {e}")
|
||||||
|
|
||||||
|
# Update last check time
|
||||||
|
self.email_address.last_check_at = timezone.now()
|
||||||
|
self.email_address.emails_processed_count += processed_count
|
||||||
|
self.email_address.save(update_fields=['last_check_at', 'emails_processed_count'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Platform: {self.email_address.display_name}] Error fetching emails: {e}")
|
||||||
|
self._update_error(str(e))
|
||||||
|
finally:
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
return processed_count
|
||||||
|
|
||||||
|
def _process_single_email(self, email_id: bytes) -> bool:
|
||||||
|
"""Process a single email message."""
|
||||||
|
status, msg_data = self.connection.fetch(email_id, '(RFC822)')
|
||||||
|
|
||||||
|
if status != 'OK':
|
||||||
|
return False
|
||||||
|
|
||||||
|
raw_email = msg_data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
email_data = self._extract_email_data(msg)
|
||||||
|
|
||||||
|
# Check for duplicate
|
||||||
|
if IncomingTicketEmail.objects.filter(message_id=email_data['message_id']).exists():
|
||||||
|
logger.info(f"Duplicate email: {email_data['message_id']}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create incoming email record
|
||||||
|
incoming_email = IncomingTicketEmail.objects.create(
|
||||||
|
message_id=email_data['message_id'],
|
||||||
|
from_address=email_data['from_address'],
|
||||||
|
from_name=email_data['from_name'],
|
||||||
|
to_address=email_data['to_address'],
|
||||||
|
subject=email_data['subject'],
|
||||||
|
body_text=email_data['body_text'],
|
||||||
|
body_html=email_data['body_html'],
|
||||||
|
extracted_reply=email_data['extracted_reply'],
|
||||||
|
raw_headers=email_data['headers'],
|
||||||
|
email_date=email_data['date'],
|
||||||
|
ticket_id_from_email=email_data.get('ticket_id', ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to match to existing ticket
|
||||||
|
ticket = self._find_matching_ticket(email_data)
|
||||||
|
user = self._find_user_by_email(email_data['from_address'])
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
# Create new ticket
|
||||||
|
return self._create_new_ticket_from_email(email_data, incoming_email, user)
|
||||||
|
|
||||||
|
# Add comment to existing ticket
|
||||||
|
if not user:
|
||||||
|
if ticket.creator and ticket.creator.email.lower() == email_data['from_address'].lower():
|
||||||
|
user = ticket.creator
|
||||||
|
elif ticket.assignee and ticket.assignee.email.lower() == email_data['from_address'].lower():
|
||||||
|
user = ticket.assignee
|
||||||
|
|
||||||
|
is_external_sender = False
|
||||||
|
if not user and ticket.external_email:
|
||||||
|
if ticket.external_email.lower() == email_data['from_address'].lower():
|
||||||
|
is_external_sender = True
|
||||||
|
|
||||||
|
if not user and not is_external_sender:
|
||||||
|
logger.warning(f"Could not match user for email from {email_data['from_address']}")
|
||||||
|
incoming_email.mark_failed("Could not match sender")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
TicketComment.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
author=user,
|
||||||
|
comment_text=email_data['extracted_reply'] or email_data['body_text'],
|
||||||
|
is_internal=False,
|
||||||
|
source=TicketComment.Source.EMAIL,
|
||||||
|
incoming_email=incoming_email,
|
||||||
|
external_author_email=email_data['from_address'] if is_external_sender else None,
|
||||||
|
external_author_name=email_data['from_name'] if is_external_sender else '',
|
||||||
|
)
|
||||||
|
|
||||||
|
if ticket.status == Ticket.Status.AWAITING_RESPONSE:
|
||||||
|
if user == ticket.creator or is_external_sender:
|
||||||
|
ticket.status = Ticket.Status.OPEN
|
||||||
|
ticket.save()
|
||||||
|
|
||||||
|
incoming_email.mark_processed(ticket=ticket, user=user)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create comment: {e}")
|
||||||
|
incoming_email.mark_failed(str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_new_ticket_from_email(self, email_data, incoming_email, user) -> bool:
|
||||||
|
"""Create a new ticket from an incoming email."""
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
subject = email_data['subject'] or 'Email Support Request'
|
||||||
|
subject = re.sub(r'^(Re|Fwd|FW|RE|FWD):\s*', '', subject, flags=re.IGNORECASE).strip()
|
||||||
|
if not subject:
|
||||||
|
subject = 'Email Support Request'
|
||||||
|
|
||||||
|
description = email_data['body_text'] or email_data['extracted_reply'] or ''
|
||||||
|
|
||||||
|
ticket = Ticket.objects.create(
|
||||||
|
tenant=None,
|
||||||
|
creator=user,
|
||||||
|
assignee=None,
|
||||||
|
ticket_type=Ticket.TicketType.PLATFORM,
|
||||||
|
status=Ticket.Status.OPEN,
|
||||||
|
priority=Ticket.Priority.LOW,
|
||||||
|
category=Ticket.Category.GENERAL_INQUIRY,
|
||||||
|
subject=subject[:255],
|
||||||
|
description=description,
|
||||||
|
is_sandbox=False,
|
||||||
|
external_email=email_data['from_address'] if not user else None,
|
||||||
|
external_name=email_data['from_name'] if not user else '',
|
||||||
|
source_email_address_id=self.email_address.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
TicketComment.objects.create(
|
||||||
|
ticket=ticket,
|
||||||
|
author=user,
|
||||||
|
comment_text=email_data['extracted_reply'] or email_data['body_text'] or description,
|
||||||
|
is_internal=False,
|
||||||
|
source=TicketComment.Source.EMAIL,
|
||||||
|
incoming_email=incoming_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
incoming_email.mark_processed(ticket=ticket, user=user)
|
||||||
|
logger.info(f"Created new ticket #{ticket.id} from email: {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create ticket from email: {e}")
|
||||||
|
incoming_email.mark_failed(str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_email_data(self, msg):
|
||||||
|
"""Extract relevant data from email message."""
|
||||||
|
message_id = msg.get('Message-ID', '') or f"generated-{timezone.now().timestamp()}"
|
||||||
|
|
||||||
|
from_name, from_address = parseaddr(msg.get('From', ''))
|
||||||
|
from_name = self._decode_header(from_name)
|
||||||
|
_, to_address = parseaddr(msg.get('To', ''))
|
||||||
|
subject = self._decode_header(msg.get('Subject', ''))
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_date = parsedate_to_datetime(msg.get('Date', ''))
|
||||||
|
except Exception:
|
||||||
|
email_date = timezone.now()
|
||||||
|
|
||||||
|
body_text, body_html = self._extract_body(msg)
|
||||||
|
extracted_reply = self._extract_reply_text(body_text)
|
||||||
|
ticket_id = self._extract_ticket_id(to_address, subject)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'from': msg.get('From', ''),
|
||||||
|
'to': msg.get('To', ''),
|
||||||
|
'subject': subject,
|
||||||
|
'date': msg.get('Date', ''),
|
||||||
|
'message-id': message_id,
|
||||||
|
'in-reply-to': msg.get('In-Reply-To', ''),
|
||||||
|
'references': msg.get('References', ''),
|
||||||
|
'x-ticket-id': msg.get('X-Ticket-ID', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'message_id': message_id,
|
||||||
|
'from_name': from_name,
|
||||||
|
'from_address': from_address.lower(),
|
||||||
|
'to_address': to_address.lower(),
|
||||||
|
'subject': subject,
|
||||||
|
'body_text': body_text,
|
||||||
|
'body_html': body_html,
|
||||||
|
'extracted_reply': extracted_reply,
|
||||||
|
'date': email_date,
|
||||||
|
'headers': headers,
|
||||||
|
'ticket_id': ticket_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _decode_header(self, header_value: str) -> str:
|
||||||
|
"""Decode an email header value."""
|
||||||
|
if not header_value:
|
||||||
|
return ''
|
||||||
|
decoded_parts = decode_header(header_value)
|
||||||
|
result = []
|
||||||
|
for content, charset in decoded_parts:
|
||||||
|
if isinstance(content, bytes):
|
||||||
|
charset = charset or 'utf-8'
|
||||||
|
try:
|
||||||
|
content = content.decode(charset)
|
||||||
|
except Exception:
|
||||||
|
content = content.decode('utf-8', errors='replace')
|
||||||
|
result.append(content)
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
def _extract_body(self, msg) -> Tuple[str, str]:
|
||||||
|
"""Extract text and HTML body from email."""
|
||||||
|
text_body = ''
|
||||||
|
html_body = ''
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
if 'attachment' in str(part.get('Content-Disposition', '')):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
body = part.get_payload(decode=True)
|
||||||
|
if body:
|
||||||
|
charset = part.get_content_charset() or 'utf-8'
|
||||||
|
body = body.decode(charset, errors='replace')
|
||||||
|
if content_type == 'text/plain':
|
||||||
|
text_body = body
|
||||||
|
elif content_type == 'text/html':
|
||||||
|
html_body = body
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
content_type = msg.get_content_type()
|
||||||
|
try:
|
||||||
|
body = msg.get_payload(decode=True)
|
||||||
|
if body:
|
||||||
|
charset = msg.get_content_charset() or 'utf-8'
|
||||||
|
body = body.decode(charset, errors='replace')
|
||||||
|
if content_type == 'text/plain':
|
||||||
|
text_body = body
|
||||||
|
elif content_type == 'text/html':
|
||||||
|
html_body = body
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not text_body and html_body:
|
||||||
|
text_body = self._html_to_text(html_body)
|
||||||
|
|
||||||
|
return text_body, html_body
|
||||||
|
|
||||||
|
def _html_to_text(self, html: str) -> str:
|
||||||
|
"""Convert HTML to plain text."""
|
||||||
|
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
|
||||||
|
text = re.sub(r'<br\s*/?>', '\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'</p>', '\n\n', text, flags=re.IGNORECASE)
|
||||||
|
text = re.sub(r'<[^>]+>', '', text)
|
||||||
|
import html as html_module
|
||||||
|
text = html_module.unescape(text)
|
||||||
|
text = re.sub(r'\n\s*\n', '\n\n', text)
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
def _extract_reply_text(self, text: str) -> str:
|
||||||
|
"""Extract reply portion, removing quoted text."""
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
lines = text.split('\n')
|
||||||
|
reply_lines = []
|
||||||
|
|
||||||
|
quote_patterns = [
|
||||||
|
r'^On .+ wrote:$',
|
||||||
|
r'^On .+, at .+, .+ wrote:$',
|
||||||
|
r'^From:.*',
|
||||||
|
r'^-{3,}\s*Original Message\s*-{3,}',
|
||||||
|
r'^_{3,}',
|
||||||
|
r'^\*From:\*',
|
||||||
|
r'^Sent from my ',
|
||||||
|
r'^Get Outlook for ',
|
||||||
|
]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
is_quote_start = any(re.match(p, stripped, re.IGNORECASE) for p in quote_patterns)
|
||||||
|
if is_quote_start:
|
||||||
|
break
|
||||||
|
if stripped.startswith('>'):
|
||||||
|
continue
|
||||||
|
reply_lines.append(line)
|
||||||
|
|
||||||
|
reply = '\n'.join(reply_lines)
|
||||||
|
reply = re.sub(r'\n\s*--\s*\n.*$', '', reply, flags=re.DOTALL)
|
||||||
|
return reply.strip()
|
||||||
|
|
||||||
|
def _extract_ticket_id(self, to_address: str, subject: str) -> str:
|
||||||
|
"""Extract ticket ID from address or subject."""
|
||||||
|
for pattern in self.TICKET_ID_PATTERNS:
|
||||||
|
match = re.search(pattern, to_address, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
for pattern in self.TICKET_ID_PATTERNS:
|
||||||
|
match = re.search(pattern, subject, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
def _find_matching_ticket(self, email_data) -> Optional[Ticket]:
|
||||||
|
"""Find the ticket this email is replying to."""
|
||||||
|
ticket_id = email_data.get('ticket_id')
|
||||||
|
if ticket_id:
|
||||||
|
try:
|
||||||
|
return Ticket.objects.get(id=int(ticket_id))
|
||||||
|
except (Ticket.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
x_ticket_id = email_data['headers'].get('x-ticket-id')
|
||||||
|
if x_ticket_id:
|
||||||
|
try:
|
||||||
|
return Ticket.objects.get(id=int(x_ticket_id))
|
||||||
|
except (Ticket.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
in_reply_to = email_data['headers'].get('in-reply-to', '')
|
||||||
|
references = email_data['headers'].get('references', '')
|
||||||
|
|
||||||
|
for ref in [in_reply_to, references]:
|
||||||
|
for pattern in self.TICKET_ID_PATTERNS:
|
||||||
|
match = re.search(pattern, ref, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return Ticket.objects.get(id=int(match.group(1)))
|
||||||
|
except (Ticket.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_user_by_email(self, email_address: str) -> Optional[User]:
|
||||||
|
"""Find a user by email address."""
|
||||||
|
try:
|
||||||
|
return User.objects.filter(email__iexact=email_address).first()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_error(self, error: str):
|
||||||
|
"""Update email address with error message."""
|
||||||
|
self.email_address.last_sync_error = error
|
||||||
|
self.email_address.last_check_at = timezone.now()
|
||||||
|
self.email_address.save(update_fields=['last_sync_error', 'last_check_at'])
|
||||||
|
|
||||||
|
|
||||||
def test_imap_connection() -> Tuple[bool, str]:
|
def test_imap_connection() -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Test IMAP connection with current settings.
|
Test IMAP connection with current settings.
|
||||||
|
|||||||
@@ -37,45 +37,44 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from tickets.email_receiver import TicketEmailReceiver
|
from tickets.email_receiver import TicketEmailReceiver
|
||||||
from tickets.models import TicketEmailSettings
|
from tickets.models import TicketEmailAddress
|
||||||
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
# Get all active email addresses (platform-wide and business-specific)
|
||||||
|
email_addresses = TicketEmailAddress.objects.filter(is_active=True)
|
||||||
|
|
||||||
if not settings.is_configured():
|
if not email_addresses.exists():
|
||||||
self.stderr.write(self.style.ERROR(
|
self.stderr.write(self.style.ERROR(
|
||||||
'Email settings not configured. Please configure IMAP settings first.'
|
'No active email addresses configured. Please configure email addresses first.'
|
||||||
))
|
))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not settings.is_enabled:
|
|
||||||
self.stderr.write(self.style.WARNING(
|
|
||||||
'Email receiving is disabled. Enable it in settings to fetch emails.'
|
|
||||||
))
|
|
||||||
if not options['daemon']:
|
|
||||||
return
|
|
||||||
|
|
||||||
receiver = TicketEmailReceiver()
|
|
||||||
|
|
||||||
if options['daemon']:
|
if options['daemon']:
|
||||||
# Daemon mode - continuous polling
|
# Daemon mode - continuous polling
|
||||||
interval = options['interval'] or settings.check_interval_seconds
|
interval = options['interval'] or 60 # Default to 60 seconds
|
||||||
self.stdout.write(self.style.SUCCESS(
|
self.stdout.write(self.style.SUCCESS(
|
||||||
f'Starting email fetch daemon (polling every {interval}s)...'
|
f'Starting email fetch daemon (polling every {interval}s for {email_addresses.count()} addresses)...'
|
||||||
))
|
))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Refresh settings in case they changed
|
# Refresh email addresses in case they changed
|
||||||
settings.refresh_from_db()
|
email_addresses = TicketEmailAddress.objects.filter(is_active=True)
|
||||||
|
|
||||||
if settings.is_enabled and settings.is_configured():
|
total_processed = 0
|
||||||
processed = receiver.fetch_and_process_emails()
|
for email_address in email_addresses:
|
||||||
if processed > 0:
|
receiver = TicketEmailReceiver(email_address)
|
||||||
self.stdout.write(
|
try:
|
||||||
f'Processed {processed} emails'
|
processed = receiver.fetch_and_process_emails()
|
||||||
)
|
total_processed += processed
|
||||||
else:
|
if processed > 0:
|
||||||
logger.debug('Email receiving disabled or not configured')
|
self.stdout.write(
|
||||||
|
f'[{email_address.display_name}] Processed {processed} emails'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(self.style.ERROR(
|
||||||
|
f'[{email_address.display_name}] Error: {e}'
|
||||||
|
))
|
||||||
|
logger.exception(f'Error fetching emails for {email_address.display_name}')
|
||||||
|
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
|
|
||||||
@@ -89,8 +88,23 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Single fetch
|
# Single fetch
|
||||||
self.stdout.write('Fetching emails...')
|
self.stdout.write(f'Fetching emails from {email_addresses.count()} addresses...')
|
||||||
processed = receiver.fetch_and_process_emails()
|
total_processed = 0
|
||||||
|
|
||||||
|
for email_address in email_addresses:
|
||||||
|
receiver = TicketEmailReceiver(email_address)
|
||||||
|
try:
|
||||||
|
processed = receiver.fetch_and_process_emails()
|
||||||
|
total_processed += processed
|
||||||
|
self.stdout.write(
|
||||||
|
f'[{email_address.display_name}] Processed {processed} emails'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(self.style.ERROR(
|
||||||
|
f'[{email_address.display_name}] Error: {e}'
|
||||||
|
))
|
||||||
|
logger.exception(f'Error fetching emails for {email_address.display_name}')
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(
|
self.stdout.write(self.style.SUCCESS(
|
||||||
f'Done. Processed {processed} emails.'
|
f'Done. Processed {total_processed} total emails.'
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 16:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0010_add_oauth_credential_model'),
|
||||||
|
('tickets', '0009_add_oauth_credential_to_email_settings'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TicketEmailAddress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('display_name', models.CharField(help_text="Display name (e.g., 'Support', 'Billing', 'Sales')", max_length=100)),
|
||||||
|
('email_address', models.EmailField(help_text='Email address for sending/receiving tickets', max_length=254)),
|
||||||
|
('color', models.CharField(default='#3b82f6', help_text='Hex color code for visual identification (e.g., #3b82f6)', max_length=7)),
|
||||||
|
('imap_host', models.CharField(help_text='IMAP server hostname (e.g., imap.gmail.com)', max_length=255)),
|
||||||
|
('imap_port', models.IntegerField(default=993, help_text='IMAP port (993 for SSL, 143 for non-SSL)')),
|
||||||
|
('imap_use_ssl', models.BooleanField(default=True, help_text='Use SSL/TLS for IMAP')),
|
||||||
|
('imap_username', models.CharField(help_text='IMAP username (usually email address)', max_length=255)),
|
||||||
|
('imap_password', models.CharField(help_text='IMAP password or app-specific password', max_length=255)),
|
||||||
|
('imap_folder', models.CharField(default='INBOX', help_text='IMAP folder to monitor', max_length=100)),
|
||||||
|
('smtp_host', models.CharField(help_text='SMTP server hostname (e.g., smtp.gmail.com)', max_length=255)),
|
||||||
|
('smtp_port', models.IntegerField(default=587, help_text='SMTP port (587 for TLS, 465 for SSL)')),
|
||||||
|
('smtp_use_tls', models.BooleanField(default=True, help_text='Use STARTTLS for SMTP')),
|
||||||
|
('smtp_use_ssl', models.BooleanField(default=False, help_text='Use SSL/TLS for SMTP (port 465)')),
|
||||||
|
('smtp_username', models.CharField(help_text='SMTP username (usually email address)', max_length=255)),
|
||||||
|
('smtp_password', models.CharField(help_text='SMTP password or app-specific password', max_length=255)),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Whether this email address is actively checked')),
|
||||||
|
('is_default', models.BooleanField(default=False, help_text='Default email for new tickets in this business')),
|
||||||
|
('last_check_at', models.DateTimeField(blank=True, help_text='When emails were last checked for this address', null=True)),
|
||||||
|
('last_error', models.TextField(blank=True, default='', help_text='Last error message if any')),
|
||||||
|
('emails_processed_count', models.IntegerField(default=0, help_text='Total number of emails processed for this address')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('tenant', models.ForeignKey(help_text='Business this email address belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='ticket_email_addresses', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Ticket Email Address',
|
||||||
|
'verbose_name_plural': 'Ticket Email Addresses',
|
||||||
|
'ordering': ['-is_default', 'display_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='incomingticketemail',
|
||||||
|
name='email_address',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Email address configuration that received this email', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='incoming_emails', to='tickets.ticketemailaddress'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='ticket',
|
||||||
|
name='source_email_address',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Email address this ticket was received from or sent to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='tickets.ticketemailaddress'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ticketemailaddress',
|
||||||
|
index=models.Index(fields=['tenant', 'is_active'], name='tickets_tic_tenant__b4c1e5_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='ticketemailaddress',
|
||||||
|
index=models.Index(fields=['email_address'], name='tickets_tic_email_a_e2aa4a_idx'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='ticketemailaddress',
|
||||||
|
unique_together={('tenant', 'email_address')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 20:06
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0010_add_oauth_credential_model'),
|
||||||
|
('tickets', '0010_ticketemailaddress_incomingticketemail_email_address_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='ticketemailaddress',
|
||||||
|
name='tenant',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Business this email address belongs to (null for platform-wide)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_email_addresses', to='core.tenant'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-01 20:13
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_email_settings_to_addresses(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Migrate data from TicketEmailSettings (singleton) to TicketEmailAddress (multi-email system).
|
||||||
|
Creates a platform-wide email address from the existing settings.
|
||||||
|
"""
|
||||||
|
TicketEmailSettings = apps.get_model('tickets', 'TicketEmailSettings')
|
||||||
|
TicketEmailAddress = apps.get_model('tickets', 'TicketEmailAddress')
|
||||||
|
|
||||||
|
# Get the existing settings
|
||||||
|
settings = TicketEmailSettings.objects.first()
|
||||||
|
|
||||||
|
if settings and settings.imap_host and settings.smtp_host:
|
||||||
|
# Check if we already have a platform email address with this email
|
||||||
|
existing = TicketEmailAddress.objects.filter(
|
||||||
|
tenant__isnull=True,
|
||||||
|
email_address=settings.support_email_address
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Create new TicketEmailAddress from settings
|
||||||
|
TicketEmailAddress.objects.create(
|
||||||
|
tenant=None, # Platform-wide
|
||||||
|
display_name='Platform Support',
|
||||||
|
email_address=settings.support_email_address or settings.smtp_from_email or settings.imap_username,
|
||||||
|
color='#3b82f6', # Default blue
|
||||||
|
# IMAP settings
|
||||||
|
imap_host=settings.imap_host,
|
||||||
|
imap_port=settings.imap_port,
|
||||||
|
imap_use_ssl=settings.imap_use_ssl,
|
||||||
|
imap_username=settings.imap_username,
|
||||||
|
imap_password=settings.imap_password,
|
||||||
|
imap_folder=settings.imap_folder,
|
||||||
|
# SMTP settings
|
||||||
|
smtp_host=settings.smtp_host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
smtp_use_tls=settings.smtp_use_tls,
|
||||||
|
smtp_use_ssl=settings.smtp_use_ssl,
|
||||||
|
smtp_username=settings.smtp_username,
|
||||||
|
smtp_password=settings.smtp_password,
|
||||||
|
# Status
|
||||||
|
is_active=settings.is_enabled,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
print(f"✓ Migrated email settings to new TicketEmailAddress: {settings.support_email_address}")
|
||||||
|
else:
|
||||||
|
print(f"✓ Platform email address already exists: {settings.support_email_address}")
|
||||||
|
else:
|
||||||
|
print("✓ No email settings to migrate")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_migration(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Reverse migration - delete the migrated platform email address.
|
||||||
|
"""
|
||||||
|
TicketEmailAddress = apps.get_model('tickets', 'TicketEmailAddress')
|
||||||
|
|
||||||
|
# Delete platform-wide email addresses
|
||||||
|
deleted_count = TicketEmailAddress.objects.filter(
|
||||||
|
tenant__isnull=True,
|
||||||
|
display_name='Platform Support'
|
||||||
|
).delete()[0]
|
||||||
|
|
||||||
|
print(f"✓ Deleted {deleted_count} platform email address(es)")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('tickets', '0011_alter_ticketemailaddress_tenant'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrate_email_settings_to_addresses, reverse_migration),
|
||||||
|
]
|
||||||
@@ -133,6 +133,16 @@ class Ticket(models.Model):
|
|||||||
help_text="Display name of external sender."
|
help_text="Display name of external sender."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Email address tracking
|
||||||
|
source_email_address = models.ForeignKey(
|
||||||
|
'TicketEmailAddress',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='tickets',
|
||||||
|
help_text="Email address this ticket was received from or sent to"
|
||||||
|
)
|
||||||
|
|
||||||
# SLA tracking
|
# SLA tracking
|
||||||
due_at = models.DateTimeField(
|
due_at = models.DateTimeField(
|
||||||
null=True,
|
null=True,
|
||||||
@@ -364,201 +374,6 @@ class TicketComment(models.Model):
|
|||||||
return f"Comment on Ticket #{self.ticket.id} by {author_str} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
return f"Comment on Ticket #{self.ticket.id} by {author_str} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailSettings(models.Model):
|
|
||||||
"""
|
|
||||||
Configuration for inbound and outbound email processing.
|
|
||||||
Singleton model - one per system (platform-wide settings).
|
|
||||||
"""
|
|
||||||
# IMAP server settings (inbound)
|
|
||||||
imap_host = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="IMAP server hostname (e.g., imap.gmail.com)"
|
|
||||||
)
|
|
||||||
imap_port = models.IntegerField(
|
|
||||||
default=993,
|
|
||||||
help_text="IMAP server port (993 for SSL, 143 for non-SSL)"
|
|
||||||
)
|
|
||||||
imap_use_ssl = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text="Use SSL/TLS connection"
|
|
||||||
)
|
|
||||||
imap_username = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="IMAP login username (usually email address)"
|
|
||||||
)
|
|
||||||
imap_password = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="IMAP login password or app-specific password"
|
|
||||||
)
|
|
||||||
imap_folder = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
default='INBOX',
|
|
||||||
help_text="IMAP folder to monitor for incoming emails"
|
|
||||||
)
|
|
||||||
|
|
||||||
# SMTP server settings (outbound)
|
|
||||||
smtp_host = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="SMTP server hostname (e.g., smtp.gmail.com)"
|
|
||||||
)
|
|
||||||
smtp_port = models.IntegerField(
|
|
||||||
default=587,
|
|
||||||
help_text="SMTP server port (587 for TLS, 465 for SSL, 25 for non-secure)"
|
|
||||||
)
|
|
||||||
smtp_use_tls = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text="Use STARTTLS encryption"
|
|
||||||
)
|
|
||||||
smtp_use_ssl = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="Use SSL/TLS encryption (usually for port 465)"
|
|
||||||
)
|
|
||||||
smtp_username = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="SMTP login username (usually email address)"
|
|
||||||
)
|
|
||||||
smtp_password = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="SMTP login password or app-specific password"
|
|
||||||
)
|
|
||||||
smtp_from_email = models.EmailField(
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="From email address for outgoing emails"
|
|
||||||
)
|
|
||||||
smtp_from_name = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="From name for outgoing emails (e.g., 'SmoothSchedule Support')"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Email address configuration
|
|
||||||
support_email_address = models.EmailField(
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="Support email address (e.g., support@yourdomain.com)"
|
|
||||||
)
|
|
||||||
support_email_domain = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="Domain for ticket reply addresses (e.g., mail.talova.net)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Processing settings
|
|
||||||
is_enabled = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
help_text="Enable inbound email processing"
|
|
||||||
)
|
|
||||||
delete_after_processing = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
help_text="Delete emails from server after successful processing"
|
|
||||||
)
|
|
||||||
check_interval_seconds = models.IntegerField(
|
|
||||||
default=60,
|
|
||||||
help_text="How often to check for new emails (in seconds)"
|
|
||||||
)
|
|
||||||
max_attachment_size_mb = models.IntegerField(
|
|
||||||
default=10,
|
|
||||||
help_text="Maximum attachment size in MB"
|
|
||||||
)
|
|
||||||
allowed_attachment_types = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
blank=True,
|
|
||||||
help_text="List of allowed attachment MIME types (empty = all allowed)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status tracking
|
|
||||||
last_check_at = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="When emails were last checked"
|
|
||||||
)
|
|
||||||
last_error = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
default='',
|
|
||||||
help_text="Last error message if any"
|
|
||||||
)
|
|
||||||
emails_processed_count = models.IntegerField(
|
|
||||||
default=0,
|
|
||||||
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)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = 'Ticket Email Settings'
|
|
||||||
verbose_name_plural = 'Ticket Email Settings'
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
status = "Enabled" if self.is_enabled else "Disabled"
|
|
||||||
return f"Ticket Email Settings ({status})"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Ensure only one instance exists (singleton)
|
|
||||||
self.pk = 1
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
|
||||||
# Prevent deletion
|
|
||||||
pass
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_instance(cls):
|
|
||||||
"""Get or create the singleton instance."""
|
|
||||||
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."""
|
|
||||||
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."""
|
|
||||||
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)."""
|
|
||||||
return self.is_imap_configured() and self.is_smtp_configured()
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingTicketEmail(models.Model):
|
class IncomingTicketEmail(models.Model):
|
||||||
"""
|
"""
|
||||||
Logs all incoming emails for ticket replies.
|
Logs all incoming emails for ticket replies.
|
||||||
@@ -637,6 +452,14 @@ class IncomingTicketEmail(models.Model):
|
|||||||
related_name='incoming_ticket_emails',
|
related_name='incoming_ticket_emails',
|
||||||
help_text="User matched by email address"
|
help_text="User matched by email address"
|
||||||
)
|
)
|
||||||
|
email_address = models.ForeignKey(
|
||||||
|
'TicketEmailAddress',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='incoming_emails',
|
||||||
|
help_text="Email address configuration that received this email"
|
||||||
|
)
|
||||||
ticket_id_from_email = models.CharField(
|
ticket_id_from_email = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -704,4 +527,148 @@ class IncomingTicketEmail(models.Model):
|
|||||||
"""Mark email as having no matching ticket."""
|
"""Mark email as having no matching ticket."""
|
||||||
self.processing_status = self.ProcessingStatus.NO_MATCH
|
self.processing_status = self.ProcessingStatus.NO_MATCH
|
||||||
self.processed_at = timezone.now()
|
self.processed_at = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEmailAddress(models.Model):
|
||||||
|
"""
|
||||||
|
Email address configuration for ticket management.
|
||||||
|
Can be tied to a business (tenant) or platform-wide (tenant=null).
|
||||||
|
Each business/platform can have multiple email addresses with their own IMAP/SMTP settings.
|
||||||
|
"""
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
Tenant,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='ticket_email_addresses',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Business this email address belongs to (null for platform-wide)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display information
|
||||||
|
display_name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
help_text="Display name (e.g., 'Support', 'Billing', 'Sales')"
|
||||||
|
)
|
||||||
|
email_address = models.EmailField(
|
||||||
|
help_text="Email address for sending/receiving tickets"
|
||||||
|
)
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
default='#3b82f6',
|
||||||
|
help_text="Hex color code for visual identification (e.g., #3b82f6)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# IMAP settings (inbound)
|
||||||
|
imap_host = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="IMAP server hostname (e.g., imap.gmail.com)"
|
||||||
|
)
|
||||||
|
imap_port = models.IntegerField(
|
||||||
|
default=993,
|
||||||
|
help_text="IMAP port (993 for SSL, 143 for non-SSL)"
|
||||||
|
)
|
||||||
|
imap_use_ssl = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Use SSL/TLS for IMAP"
|
||||||
|
)
|
||||||
|
imap_username = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="IMAP username (usually email address)"
|
||||||
|
)
|
||||||
|
imap_password = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="IMAP password or app-specific password"
|
||||||
|
)
|
||||||
|
imap_folder = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
default='INBOX',
|
||||||
|
help_text="IMAP folder to monitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
# SMTP settings (outbound)
|
||||||
|
smtp_host = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="SMTP server hostname (e.g., smtp.gmail.com)"
|
||||||
|
)
|
||||||
|
smtp_port = models.IntegerField(
|
||||||
|
default=587,
|
||||||
|
help_text="SMTP port (587 for TLS, 465 for SSL)"
|
||||||
|
)
|
||||||
|
smtp_use_tls = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Use STARTTLS for SMTP"
|
||||||
|
)
|
||||||
|
smtp_use_ssl = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Use SSL/TLS for SMTP (port 465)"
|
||||||
|
)
|
||||||
|
smtp_username = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="SMTP username (usually email address)"
|
||||||
|
)
|
||||||
|
smtp_password = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
help_text="SMTP password or app-specific password"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status and tracking
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this email address is actively checked"
|
||||||
|
)
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Default email for new tickets in this business"
|
||||||
|
)
|
||||||
|
last_check_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When emails were last checked for this address"
|
||||||
|
)
|
||||||
|
last_error = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text="Last error message if any"
|
||||||
|
)
|
||||||
|
emails_processed_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Total number of emails processed for this address"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-is_default', 'display_name']
|
||||||
|
unique_together = [['tenant', 'email_address']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['tenant', 'is_active']),
|
||||||
|
models.Index(fields=['email_address']),
|
||||||
|
]
|
||||||
|
verbose_name = 'Ticket Email Address'
|
||||||
|
verbose_name_plural = 'Ticket Email Addresses'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.display_name} <{self.email_address}> ({self.tenant.name})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Ensure only one default per tenant
|
||||||
|
if self.is_default:
|
||||||
|
TicketEmailAddress.objects.filter(
|
||||||
|
tenant=self.tenant,
|
||||||
|
is_default=True
|
||||||
|
).exclude(pk=self.pk).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def is_imap_configured(self):
|
||||||
|
"""Check if IMAP settings are properly configured."""
|
||||||
|
return bool(self.imap_host and self.imap_username and self.imap_password)
|
||||||
|
|
||||||
|
def is_smtp_configured(self):
|
||||||
|
"""Check if SMTP settings are properly configured."""
|
||||||
|
return bool(self.smtp_host and self.smtp_username and self.smtp_password)
|
||||||
|
|
||||||
|
def is_fully_configured(self):
|
||||||
|
"""Check if both IMAP and SMTP are configured."""
|
||||||
|
return self.is_imap_configured() and self.is_smtp_configured()
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
|
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
from core.models import Tenant
|
from core.models import Tenant
|
||||||
|
|
||||||
@@ -16,6 +16,19 @@ class TicketCommentSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at', 'source', 'source_display']
|
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at', 'source', 'source_display']
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEmailAddressListSerializer(serializers.ModelSerializer):
|
||||||
|
"""Lightweight serializer for listing email addresses (no passwords)."""
|
||||||
|
class Meta:
|
||||||
|
model = TicketEmailAddress
|
||||||
|
fields = [
|
||||||
|
'id', 'display_name', 'email_address', 'color',
|
||||||
|
'is_active', 'is_default', 'last_check_at',
|
||||||
|
'emails_processed_count', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['last_check_at', 'emails_processed_count', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
class TicketSerializer(serializers.ModelSerializer):
|
class TicketSerializer(serializers.ModelSerializer):
|
||||||
creator_email = serializers.ReadOnlyField(source='creator.email')
|
creator_email = serializers.ReadOnlyField(source='creator.email')
|
||||||
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
|
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
|
||||||
@@ -23,6 +36,7 @@ class TicketSerializer(serializers.ModelSerializer):
|
|||||||
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
|
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
|
||||||
is_overdue = serializers.ReadOnlyField()
|
is_overdue = serializers.ReadOnlyField()
|
||||||
comments = TicketCommentSerializer(many=True, read_only=True)
|
comments = TicketCommentSerializer(many=True, read_only=True)
|
||||||
|
source_email_address = TicketEmailAddressListSerializer(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Ticket
|
model = Ticket
|
||||||
@@ -32,7 +46,7 @@ class TicketSerializer(serializers.ModelSerializer):
|
|||||||
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
|
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
|
||||||
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
|
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
|
||||||
'created_at', 'updated_at', 'resolved_at', 'comments',
|
'created_at', 'updated_at', 'resolved_at', 'comments',
|
||||||
'external_email', 'external_name'
|
'external_email', 'external_name', 'source_email_address'
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
|
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
|
||||||
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments',
|
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments',
|
||||||
@@ -141,90 +155,6 @@ class CannedResponseSerializer(serializers.ModelSerializer):
|
|||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailSettingsSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serializer for ticket email settings (platform-wide configuration)."""
|
|
||||||
is_configured = serializers.SerializerMethodField()
|
|
||||||
is_imap_configured = serializers.SerializerMethodField()
|
|
||||||
is_smtp_configured = serializers.SerializerMethodField()
|
|
||||||
imap_password_masked = serializers.SerializerMethodField()
|
|
||||||
smtp_password_masked = serializers.SerializerMethodField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TicketEmailSettings
|
|
||||||
fields = [
|
|
||||||
# IMAP settings
|
|
||||||
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
|
|
||||||
'imap_password', 'imap_password_masked', 'imap_folder',
|
|
||||||
# SMTP settings
|
|
||||||
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
|
|
||||||
'smtp_password', 'smtp_password_masked', 'smtp_from_email', 'smtp_from_name',
|
|
||||||
# General settings
|
|
||||||
'support_email_address', 'support_email_domain',
|
|
||||||
'is_enabled', 'delete_after_processing', 'check_interval_seconds',
|
|
||||||
'max_attachment_size_mb', 'allowed_attachment_types',
|
|
||||||
# Status fields
|
|
||||||
'last_check_at', 'last_error', 'emails_processed_count',
|
|
||||||
'is_configured', 'is_imap_configured', 'is_smtp_configured',
|
|
||||||
'created_at', 'updated_at'
|
|
||||||
]
|
|
||||||
read_only_fields = [
|
|
||||||
'last_check_at', 'last_error', 'emails_processed_count',
|
|
||||||
'is_configured', 'is_imap_configured', 'is_smtp_configured',
|
|
||||||
'imap_password_masked', 'smtp_password_masked',
|
|
||||||
'created_at', 'updated_at'
|
|
||||||
]
|
|
||||||
extra_kwargs = {
|
|
||||||
'imap_password': {'write_only': True},
|
|
||||||
'smtp_password': {'write_only': True}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_is_configured(self, obj):
|
|
||||||
return obj.is_configured()
|
|
||||||
|
|
||||||
def get_is_imap_configured(self, obj):
|
|
||||||
return obj.is_imap_configured()
|
|
||||||
|
|
||||||
def get_is_smtp_configured(self, obj):
|
|
||||||
return obj.is_smtp_configured()
|
|
||||||
|
|
||||||
def get_imap_password_masked(self, obj):
|
|
||||||
if obj.imap_password:
|
|
||||||
return '********'
|
|
||||||
return ''
|
|
||||||
|
|
||||||
def get_smtp_password_masked(self, obj):
|
|
||||||
if obj.smtp_password:
|
|
||||||
return '********'
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailSettingsUpdateSerializer(serializers.ModelSerializer):
|
|
||||||
"""Serializer for updating email settings (allows partial updates)."""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TicketEmailSettings
|
|
||||||
fields = [
|
|
||||||
# IMAP settings
|
|
||||||
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
|
|
||||||
'imap_password', 'imap_folder',
|
|
||||||
# SMTP settings
|
|
||||||
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl', 'smtp_username',
|
|
||||||
'smtp_password', 'smtp_from_email', 'smtp_from_name',
|
|
||||||
# General settings
|
|
||||||
'support_email_address', 'support_email_domain',
|
|
||||||
'is_enabled', 'delete_after_processing', 'check_interval_seconds',
|
|
||||||
'max_attachment_size_mb', 'allowed_attachment_types',
|
|
||||||
]
|
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
|
||||||
# Only update passwords if new ones are provided
|
|
||||||
if 'imap_password' in validated_data and not validated_data['imap_password']:
|
|
||||||
validated_data.pop('imap_password')
|
|
||||||
if 'smtp_password' in validated_data and not validated_data['smtp_password']:
|
|
||||||
validated_data.pop('smtp_password')
|
|
||||||
return super().update(instance, validated_data)
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingTicketEmailSerializer(serializers.ModelSerializer):
|
class IncomingTicketEmailSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for incoming email records."""
|
"""Serializer for incoming email records."""
|
||||||
processing_status_display = serializers.CharField(source='get_processing_status_display', read_only=True)
|
processing_status_display = serializers.CharField(source='get_processing_status_display', read_only=True)
|
||||||
@@ -253,3 +183,41 @@ class IncomingTicketEmailListSerializer(serializers.ModelSerializer):
|
|||||||
'ticket', 'processing_status', 'processing_status_display',
|
'ticket', 'processing_status', 'processing_status_display',
|
||||||
'email_date', 'received_at'
|
'email_date', 'received_at'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEmailAddressSerializer(serializers.ModelSerializer):
|
||||||
|
"""Full serializer for email addresses with all settings."""
|
||||||
|
tenant_name = serializers.SerializerMethodField()
|
||||||
|
is_imap_configured = serializers.ReadOnlyField()
|
||||||
|
is_smtp_configured = serializers.ReadOnlyField()
|
||||||
|
is_fully_configured = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
def get_tenant_name(self, obj):
|
||||||
|
return obj.tenant.name if obj.tenant else 'Platform'
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TicketEmailAddress
|
||||||
|
fields = [
|
||||||
|
'id', 'tenant', 'tenant_name', 'display_name', 'email_address', 'color',
|
||||||
|
'imap_host', 'imap_port', 'imap_use_ssl', 'imap_username',
|
||||||
|
'imap_password', 'imap_folder',
|
||||||
|
'smtp_host', 'smtp_port', 'smtp_use_tls', 'smtp_use_ssl',
|
||||||
|
'smtp_username', 'smtp_password',
|
||||||
|
'is_active', 'is_default', 'last_check_at', 'last_error',
|
||||||
|
'emails_processed_count', 'created_at', 'updated_at',
|
||||||
|
'is_imap_configured', 'is_smtp_configured', 'is_fully_configured'
|
||||||
|
]
|
||||||
|
read_only_fields = ['tenant', 'tenant_name', 'last_check_at', 'last_error',
|
||||||
|
'emails_processed_count', 'created_at', 'updated_at',
|
||||||
|
'is_imap_configured', 'is_smtp_configured', 'is_fully_configured']
|
||||||
|
extra_kwargs = {
|
||||||
|
'imap_password': {'write_only': True},
|
||||||
|
'smtp_password': {'write_only': True},
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
# Automatically set tenant from current user
|
||||||
|
if 'tenant' not in validated_data and self.context['request'].user.is_authenticated:
|
||||||
|
if hasattr(self.context['request'].user, 'tenant') and self.context['request'].user.tenant:
|
||||||
|
validated_data['tenant'] = self.context['request'].user.tenant
|
||||||
|
return super().create(validated_data)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def fetch_incoming_emails(self):
|
def fetch_incoming_emails(self):
|
||||||
"""
|
"""
|
||||||
Celery task to fetch and process incoming ticket emails.
|
Celery task to fetch and process incoming ticket emails.
|
||||||
|
Processes emails for all active email addresses (platform and business).
|
||||||
|
|
||||||
This task should be scheduled to run periodically (e.g., every minute)
|
This task should be scheduled to run periodically (e.g., every minute)
|
||||||
via Celery Beat.
|
via Celery Beat.
|
||||||
@@ -30,29 +31,75 @@ def fetch_incoming_emails(self):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
from .email_receiver import TicketEmailReceiver
|
from .email_receiver import TicketEmailReceiver, PlatformEmailReceiver
|
||||||
from .models import TicketEmailSettings
|
from .models import TicketEmailAddress
|
||||||
|
from platform_admin.models import PlatformEmailAddress
|
||||||
|
|
||||||
# Check if email receiving is enabled
|
total_processed = 0
|
||||||
settings = TicketEmailSettings.get_instance()
|
results = []
|
||||||
|
|
||||||
if not settings.is_enabled:
|
# Process platform email addresses (default one for tickets)
|
||||||
logger.debug("Ticket email receiving is disabled")
|
try:
|
||||||
return {'status': 'disabled', 'processed': 0}
|
default_platform_email = PlatformEmailAddress.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
is_default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
if not settings.is_configured():
|
if default_platform_email:
|
||||||
logger.warning("Ticket email settings not configured")
|
try:
|
||||||
return {'status': 'not_configured', 'processed': 0}
|
receiver = PlatformEmailReceiver(default_platform_email)
|
||||||
|
processed = receiver.fetch_and_process_emails()
|
||||||
|
total_processed += processed
|
||||||
|
results.append({
|
||||||
|
'address': default_platform_email.display_name,
|
||||||
|
'type': 'platform',
|
||||||
|
'processed': processed,
|
||||||
|
'status': 'success'
|
||||||
|
})
|
||||||
|
logger.info(f"[Platform: {default_platform_email.display_name}] Processed {processed} emails")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Platform: {default_platform_email.display_name}] Error processing emails: {e}")
|
||||||
|
results.append({
|
||||||
|
'address': default_platform_email.display_name,
|
||||||
|
'type': 'platform',
|
||||||
|
'processed': 0,
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching platform email addresses: {e}")
|
||||||
|
|
||||||
# Process emails
|
# Process tenant/business email addresses
|
||||||
receiver = TicketEmailReceiver()
|
email_addresses = TicketEmailAddress.objects.filter(is_active=True)
|
||||||
processed_count = receiver.fetch_and_process_emails()
|
|
||||||
|
|
||||||
logger.info(f"Processed {processed_count} incoming ticket emails")
|
for email_address in email_addresses:
|
||||||
|
try:
|
||||||
|
receiver = TicketEmailReceiver(email_address)
|
||||||
|
processed = receiver.fetch_and_process_emails()
|
||||||
|
total_processed += processed
|
||||||
|
results.append({
|
||||||
|
'address': email_address.display_name,
|
||||||
|
'type': 'tenant',
|
||||||
|
'processed': processed,
|
||||||
|
'status': 'success'
|
||||||
|
})
|
||||||
|
logger.info(f"[Tenant: {email_address.display_name}] Processed {processed} emails")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Tenant: {email_address.display_name}] Error processing emails: {e}")
|
||||||
|
results.append({
|
||||||
|
'address': email_address.display_name,
|
||||||
|
'type': 'tenant',
|
||||||
|
'processed': 0,
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Processed {total_processed} total incoming ticket emails from {len(results)} addresses")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'processed': processed_count,
|
'processed': total_processed,
|
||||||
|
'results': results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from .views import (
|
from .views import (
|
||||||
TicketViewSet, TicketCommentViewSet,
|
TicketViewSet, TicketCommentViewSet,
|
||||||
TicketTemplateViewSet, CannedResponseViewSet,
|
TicketTemplateViewSet, CannedResponseViewSet,
|
||||||
TicketEmailSettingsView, TicketEmailTestConnectionView,
|
IncomingTicketEmailViewSet, EmailProviderDetectView,
|
||||||
TicketEmailTestSmtpView, TicketEmailFetchNowView,
|
TicketEmailAddressViewSet, RefreshTicketEmailsView
|
||||||
IncomingTicketEmailViewSet, EmailProviderDetectView
|
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'tickets'
|
app_name = 'tickets'
|
||||||
@@ -32,17 +31,20 @@ canned_router.register(r'', CannedResponseViewSet, basename='canned-response')
|
|||||||
incoming_emails_router = DefaultRouter()
|
incoming_emails_router = DefaultRouter()
|
||||||
incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incoming-email')
|
incoming_emails_router.register(r'', IncomingTicketEmailViewSet, basename='incoming-email')
|
||||||
|
|
||||||
urlpatterns = [
|
# Router for email addresses
|
||||||
# Email settings endpoints (platform admin only) - must be BEFORE router.urls
|
email_addresses_router = DefaultRouter()
|
||||||
path('email-settings/', TicketEmailSettingsView.as_view(), name='email-settings'),
|
email_addresses_router.register(r'', TicketEmailAddressViewSet, basename='email-address')
|
||||||
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'),
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
# Incoming emails audit log - must be BEFORE router.urls
|
# Incoming emails audit log - must be BEFORE router.urls
|
||||||
path('incoming-emails/', include(incoming_emails_router.urls)),
|
path('incoming-emails/', include(incoming_emails_router.urls)),
|
||||||
|
|
||||||
|
# Email addresses (per-business and platform email configuration)
|
||||||
|
path('email-addresses/', include(email_addresses_router.urls)),
|
||||||
|
|
||||||
|
# Manual email refresh endpoint
|
||||||
|
path('refresh-emails/', RefreshTicketEmailsView.as_view(), name='refresh-emails'),
|
||||||
|
|
||||||
# Other static paths
|
# Other static paths
|
||||||
path('templates/', include(templates_router.urls)),
|
path('templates/', include(templates_router.urls)),
|
||||||
path('canned-responses/', include(canned_router.urls)),
|
path('canned-responses/', include(canned_router.urls)),
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ from rest_framework.filters import OrderingFilter, SearchFilter
|
|||||||
|
|
||||||
from core.models import Tenant
|
from core.models import Tenant
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, TicketEmailSettings, IncomingTicketEmail
|
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse, IncomingTicketEmail, TicketEmailAddress
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
|
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
|
||||||
TicketTemplateSerializer, CannedResponseSerializer,
|
TicketTemplateSerializer, CannedResponseSerializer,
|
||||||
TicketEmailSettingsSerializer, TicketEmailSettingsUpdateSerializer,
|
IncomingTicketEmailSerializer, IncomingTicketEmailListSerializer,
|
||||||
IncomingTicketEmailSerializer, IncomingTicketEmailListSerializer
|
TicketEmailAddressSerializer, TicketEmailAddressListSerializer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,167 +376,6 @@ class IsPlatformAdmin(IsAuthenticated):
|
|||||||
return is_platform_admin(request.user)
|
return is_platform_admin(request.user)
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailSettingsView(APIView):
|
|
||||||
"""
|
|
||||||
API endpoint for managing ticket email settings (inbound email configuration).
|
|
||||||
Only accessible by platform administrators.
|
|
||||||
|
|
||||||
GET: Retrieve current email settings
|
|
||||||
PUT/PATCH: Update email settings
|
|
||||||
"""
|
|
||||||
permission_classes = [IsPlatformAdmin]
|
|
||||||
|
|
||||||
def get(self, request):
|
|
||||||
"""Get current email settings."""
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
|
||||||
serializer = TicketEmailSettingsSerializer(settings)
|
|
||||||
return Response(serializer.data)
|
|
||||||
|
|
||||||
def put(self, request):
|
|
||||||
"""Update all email settings."""
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
|
||||||
serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
# Return full settings with read-only fields
|
|
||||||
return Response(TicketEmailSettingsSerializer(settings).data)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
def patch(self, request):
|
|
||||||
"""Partially update email settings."""
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
|
||||||
serializer = TicketEmailSettingsUpdateSerializer(settings, data=request.data, partial=True)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
# Return full settings with read-only fields
|
|
||||||
return Response(TicketEmailSettingsSerializer(settings).data)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailTestConnectionView(APIView):
|
|
||||||
"""
|
|
||||||
API endpoint to test IMAP connection with current settings.
|
|
||||||
Only accessible by platform administrators.
|
|
||||||
|
|
||||||
POST: Test the IMAP connection
|
|
||||||
"""
|
|
||||||
permission_classes = [IsPlatformAdmin]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
"""Test IMAP connection with current settings."""
|
|
||||||
from .email_receiver import test_imap_connection
|
|
||||||
|
|
||||||
success, message = test_imap_connection()
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'success': success,
|
|
||||||
'message': message,
|
|
||||||
}, status=status.HTTP_200_OK if success else status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailTestSmtpView(APIView):
|
|
||||||
"""
|
|
||||||
API endpoint to test SMTP connection with current settings.
|
|
||||||
Only accessible by platform administrators.
|
|
||||||
|
|
||||||
POST: Test the SMTP connection
|
|
||||||
"""
|
|
||||||
permission_classes = [IsPlatformAdmin]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
"""Test SMTP connection with current settings."""
|
|
||||||
import smtplib
|
|
||||||
import ssl
|
|
||||||
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
|
||||||
|
|
||||||
if not settings.smtp_host or not settings.smtp_username or not settings.smtp_password:
|
|
||||||
return Response({
|
|
||||||
'success': False,
|
|
||||||
'message': 'SMTP settings not configured. Please provide host, username, and password.',
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
try:
|
|
||||||
if settings.smtp_use_ssl:
|
|
||||||
# SSL connection (typically port 465)
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
server = smtplib.SMTP_SSL(
|
|
||||||
settings.smtp_host,
|
|
||||||
settings.smtp_port,
|
|
||||||
context=context,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Regular connection with optional STARTTLS
|
|
||||||
server = smtplib.SMTP(
|
|
||||||
settings.smtp_host,
|
|
||||||
settings.smtp_port,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
server.ehlo()
|
|
||||||
if settings.smtp_use_tls:
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
server.starttls(context=context)
|
|
||||||
server.ehlo()
|
|
||||||
|
|
||||||
# Authenticate
|
|
||||||
server.login(settings.smtp_username, settings.smtp_password)
|
|
||||||
server.quit()
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'message': f'Successfully connected to SMTP server at {settings.smtp_host}:{settings.smtp_port}',
|
|
||||||
})
|
|
||||||
|
|
||||||
except smtplib.SMTPAuthenticationError as e:
|
|
||||||
return Response({
|
|
||||||
'success': False,
|
|
||||||
'message': f'SMTP authentication failed: {str(e)}',
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
except smtplib.SMTPConnectError as e:
|
|
||||||
return Response({
|
|
||||||
'success': False,
|
|
||||||
'message': f'Failed to connect to SMTP server: {str(e)}',
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
except Exception as e:
|
|
||||||
return Response({
|
|
||||||
'success': False,
|
|
||||||
'message': f'SMTP connection error: {str(e)}',
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
|
|
||||||
class TicketEmailFetchNowView(APIView):
|
|
||||||
"""
|
|
||||||
API endpoint to manually trigger email fetch.
|
|
||||||
Only accessible by platform administrators.
|
|
||||||
|
|
||||||
POST: Trigger immediate email fetch
|
|
||||||
"""
|
|
||||||
permission_classes = [IsPlatformAdmin]
|
|
||||||
|
|
||||||
def post(self, request):
|
|
||||||
"""Manually trigger email fetch."""
|
|
||||||
from .email_receiver import TicketEmailReceiver
|
|
||||||
|
|
||||||
settings = TicketEmailSettings.get_instance()
|
|
||||||
|
|
||||||
if not settings.is_imap_configured():
|
|
||||||
return Response({
|
|
||||||
'success': False,
|
|
||||||
'message': 'IMAP settings not configured',
|
|
||||||
'processed': 0,
|
|
||||||
}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
receiver = TicketEmailReceiver()
|
|
||||||
processed_count = receiver.fetch_and_process_emails()
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'success': True,
|
|
||||||
'message': f'Successfully fetched and processed {processed_count} emails',
|
|
||||||
'processed': processed_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet):
|
class IncomingTicketEmailViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint for viewing incoming email records (audit log).
|
API endpoint for viewing incoming email records (audit log).
|
||||||
@@ -939,4 +778,207 @@ class EmailProviderDetectView(APIView):
|
|||||||
# Provide common default port suggestions
|
# Provide common default port suggestions
|
||||||
'suggested_imap_port': 993,
|
'suggested_imap_port': 993,
|
||||||
'suggested_smtp_port': 587,
|
'suggested_smtp_port': 587,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class TicketEmailAddressViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing ticket email addresses.
|
||||||
|
Business owners and managers can manage their own email addresses.
|
||||||
|
Platform admins can view all email addresses.
|
||||||
|
"""
|
||||||
|
serializer_class = TicketEmailAddressSerializer
|
||||||
|
permission_classes = [IsTenantUser]
|
||||||
|
filter_backends = [SearchFilter, OrderingFilter]
|
||||||
|
search_fields = ['display_name', 'email_address']
|
||||||
|
ordering_fields = ['display_name', 'email_address', 'is_default', 'created_at']
|
||||||
|
ordering = ['-is_default', 'display_name']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# Platform users see platform-wide email addresses (tenant=null)
|
||||||
|
if is_platform_admin(user):
|
||||||
|
return TicketEmailAddress.objects.filter(tenant__isnull=True).select_related('tenant')
|
||||||
|
|
||||||
|
# Business users see only their own email addresses
|
||||||
|
if hasattr(user, 'tenant') and user.tenant:
|
||||||
|
# Only owners and managers can view/manage email addresses
|
||||||
|
if user.role in [User.Role.OWNER, User.Role.MANAGER]:
|
||||||
|
return TicketEmailAddress.objects.filter(tenant=user.tenant)
|
||||||
|
|
||||||
|
return TicketEmailAddress.objects.none()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'list':
|
||||||
|
return TicketEmailAddressListSerializer
|
||||||
|
return TicketEmailAddressSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
# Set tenant based on user type
|
||||||
|
user = self.request.user
|
||||||
|
if is_platform_admin(user):
|
||||||
|
# Platform admins create platform-wide email addresses (tenant=None)
|
||||||
|
serializer.save(tenant=None)
|
||||||
|
elif hasattr(user, 'tenant') and user.tenant:
|
||||||
|
# Business users create email addresses for their business
|
||||||
|
serializer.save(tenant=user.tenant)
|
||||||
|
else:
|
||||||
|
# Should not happen - validation should catch this
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_imap(self, request, pk=None):
|
||||||
|
"""Test IMAP connection for this email address."""
|
||||||
|
email_address = self.get_object()
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .email_receiver import TicketEmailReceiver
|
||||||
|
|
||||||
|
try:
|
||||||
|
receiver = TicketEmailReceiver(email_address)
|
||||||
|
if receiver.connect():
|
||||||
|
receiver.disconnect()
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'IMAP connection successful'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Failed to connect to IMAP server'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'IMAP connection failed: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def test_smtp(self, request, pk=None):
|
||||||
|
"""Test SMTP connection for this email address."""
|
||||||
|
email_address = self.get_object()
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .email_notifications import TicketEmailService
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = TicketEmailService()
|
||||||
|
# Test connection using this email address's settings
|
||||||
|
success = service._test_smtp_connection(email_address)
|
||||||
|
if success:
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': 'SMTP connection successful'
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': 'Failed to connect to SMTP server'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'SMTP connection failed: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def fetch_now(self, request, pk=None):
|
||||||
|
"""Manually trigger email fetch for this address."""
|
||||||
|
email_address = self.get_object()
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .email_receiver import TicketEmailReceiver
|
||||||
|
|
||||||
|
try:
|
||||||
|
receiver = TicketEmailReceiver(email_address)
|
||||||
|
processed = receiver.fetch_and_process_emails()
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'Fetched {processed} emails',
|
||||||
|
'processed': processed,
|
||||||
|
'errors': 0
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return Response({
|
||||||
|
'success': False,
|
||||||
|
'message': f'Failed to fetch emails: {str(e)}'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def set_as_default(self, request, pk=None):
|
||||||
|
"""Set this email address as the default for the business."""
|
||||||
|
email_address = self.get_object()
|
||||||
|
|
||||||
|
# Set this as default and unset all others for this tenant
|
||||||
|
TicketEmailAddress.objects.filter(
|
||||||
|
tenant=email_address.tenant,
|
||||||
|
is_default=True
|
||||||
|
).exclude(pk=email_address.pk).update(is_default=False)
|
||||||
|
|
||||||
|
email_address.is_default = True
|
||||||
|
email_address.save()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'message': f'{email_address.display_name} is now the default email address'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTicketEmailsView(APIView):
|
||||||
|
"""
|
||||||
|
POST /api/tickets/refresh-emails/
|
||||||
|
Manually trigger a check for new incoming emails.
|
||||||
|
Platform admins only.
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if not is_platform_admin(request.user):
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only platform administrators can refresh emails'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
from .email_receiver import PlatformEmailReceiver
|
||||||
|
from platform_admin.models import PlatformEmailAddress
|
||||||
|
|
||||||
|
results = []
|
||||||
|
total_processed = 0
|
||||||
|
|
||||||
|
# Check default platform email address
|
||||||
|
try:
|
||||||
|
default_email = PlatformEmailAddress.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
is_default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if default_email:
|
||||||
|
receiver = PlatformEmailReceiver(default_email)
|
||||||
|
processed = receiver.fetch_and_process_emails()
|
||||||
|
total_processed += processed
|
||||||
|
results.append({
|
||||||
|
'address': default_email.email_address,
|
||||||
|
'display_name': default_email.display_name,
|
||||||
|
'processed': processed,
|
||||||
|
'status': 'success',
|
||||||
|
'last_check_at': default_email.last_check_at.isoformat() if default_email.last_check_at else None,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'address': None,
|
||||||
|
'status': 'no_default',
|
||||||
|
'message': 'No default platform email address configured'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({
|
||||||
|
'address': 'platform',
|
||||||
|
'status': 'error',
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'processed': total_processed,
|
||||||
|
'results': results,
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user