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:
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;
|
||||
Reference in New Issue
Block a user