Files
smoothschedule/frontend/src/components/TicketEmailAddressManager.tsx
poduck ae74b4c2ed 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>
2025-12-01 17:49:09 -05:00

280 lines
10 KiB
TypeScript

/**
* 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;