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:
poduck
2025-12-01 17:49:09 -05:00
parent 65da1c73d0
commit ae74b4c2ed
47 changed files with 6523 additions and 1407 deletions

View 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] });
},
});
};

View File

@@ -15,9 +15,14 @@ export interface PlatformSettings {
stripe_validation_error: string;
has_stripe_keys: boolean;
stripe_keys_from_env: boolean;
email_check_interval_minutes: number;
updated_at: string;
}
export interface GeneralSettingsUpdate {
email_check_interval_minutes?: number;
}
export interface StripeKeysUpdate {
stripe_secret_key?: string;
stripe_publishable_key?: string;
@@ -35,10 +40,14 @@ export interface SubscriptionPlan {
price_yearly: string | null;
business_tier: string;
features: string[];
limits: Record<string, any>;
permissions: Record<string, boolean>;
transaction_fee_percent: string;
transaction_fee_fixed: string;
is_active: boolean;
is_public: boolean;
is_most_popular: boolean;
show_price: boolean;
created_at: string;
updated_at: string;
}
@@ -51,10 +60,14 @@ export interface SubscriptionPlanCreate {
price_yearly?: number | null;
business_tier?: string;
features?: string[];
limits?: Record<string, any>;
permissions?: Record<string, boolean>;
transaction_fee_percent?: number;
transaction_fee_fixed?: number;
is_active?: boolean;
is_public?: boolean;
is_most_popular?: boolean;
show_price?: boolean;
create_stripe_product?: boolean;
stripe_product_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
*/
@@ -148,7 +178,7 @@ export const useUpdateSubscriptionPlan = () => {
const queryClient = useQueryClient();
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);
return data;
},

View 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,
};

View File

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