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

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
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 SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -63,6 +63,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<MessageSquare size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.support')}</span>}
</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 && (
<>
@@ -84,6 +88,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<HelpCircle size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.help', 'Help')}</span>}
</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')}>
<Code size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.apiDocs', 'API Docs')}</span>}

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

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