feat: Gitea Webhook, IMAP, Settings & Deployment docs

- Webhook route and Gitea integration
- IMAP service and Nextcloud/Porkbun setup docs
- Settings UI improvements and API updates
- SSH/Webhook fix prompt for emailsorter.webklar.com
- Bootstrap, config and AI sorter updates
This commit is contained in:
2026-01-31 15:00:00 +01:00
parent 7e7ec1013b
commit cbb225c001
24 changed files with 2173 additions and 32 deletions

View File

@@ -56,7 +56,7 @@ export const api = {
return fetchApi<Array<{
id: string
email: string
provider: 'gmail' | 'outlook'
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
lastSync?: string
}>>(`/email/accounts?userId=${userId}`)
@@ -69,6 +69,24 @@ export const api = {
})
},
async connectImapAccount(
userId: string,
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean }
) {
return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST',
body: JSON.stringify({
userId,
provider: 'imap',
email: params.email,
accessToken: params.password,
imapHost: params.imapHost,
imapPort: params.imapPort,
imapSecure: params.imapSecure,
}),
})
},
async disconnectEmailAccount(accountId: string, userId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
method: 'DELETE',
@@ -403,6 +421,49 @@ export const api = {
})
},
// ═══════════════════════════════════════════════════════════════════════════
// ME / ADMIN
// ═══════════════════════════════════════════════════════════════════════════
async getMe(email: string) {
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// NAME LABELS (Workers Admin only)
// ═══════════════════════════════════════════════════════════════════════════
async getNameLabels(userId: string, email: string) {
return fetchApi<Array<{
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
},
async saveNameLabel(
userId: string,
userEmail: string,
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
) {
return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>(
'/preferences/name-labels',
{
method: 'POST',
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
}
)
},
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
return fetchApi<{ success: boolean }>(
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
{ method: 'DELETE' }
)
},
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCTS & QUESTIONS (Legacy)
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -905,10 +905,10 @@ export function Dashboard() {
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : 'bg-blue-100 dark:bg-blue-900/50'
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
}`}>
<Mail className={`w-3 h-3 ${
account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'
}`} />
</div>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{account.email}</p>

View File

@@ -22,6 +22,7 @@ import { api } from '@/lib/api'
import {
Mail,
User,
Users,
CreditCard,
Shield,
Settings as SettingsIcon,
@@ -54,15 +55,15 @@ import {
Save,
Edit2,
} from 'lucide-react'
import type { AIControlSettings, CompanyLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
import type { AIControlSettings, CompanyLabel, NameLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
import { PrivacySecurity } from '@/components/PrivacySecurity'
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'subscription' | 'privacy' | 'referrals'
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
interface EmailAccount {
id: string
email: string
provider: 'gmail' | 'outlook'
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
lastSync?: string
}
@@ -97,6 +98,9 @@ export function Settings() {
const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
const [showImapForm, setShowImapForm] = useState(false)
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
const [imapConnecting, setImapConnecting] = useState(false)
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
const [newVipEmail, setNewVipEmail] = useState('')
const [subscription, setSubscription] = useState<Subscription | null>(null)
@@ -126,6 +130,10 @@ export function Settings() {
})
const [categories, setCategories] = useState<CategoryInfo[]>([])
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
const [isAdmin, setIsAdmin] = useState(false)
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
const [loadingReferral, setLoadingReferral] = useState(false)
@@ -185,16 +193,24 @@ export function Settings() {
setLoading(true)
try {
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes] = await Promise.all([
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
api.getEmailAccounts(user.$id),
api.getSubscriptionStatus(user.$id),
api.getUserPreferences(user.$id),
api.getAIControlSettings(user.$id),
api.getCompanyLabels(user.$id),
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
])
if (accountsRes.data) setAccounts(accountsRes.data)
if (subsRes.data) setSubscription(subsRes.data)
if (meRes.data?.isAdmin) {
setIsAdmin(true)
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
} else {
setIsAdmin(false)
}
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
if (aiControlRes.data) {
// Merge cleanup defaults if not present
@@ -478,6 +494,31 @@ export function Settings() {
}
}
const handleConnectImap = async (e: React.FormEvent) => {
e.preventDefault()
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
setImapConnecting(true)
const res = await api.connectImapAccount(user.$id, {
email: imapForm.email.trim(),
password: imapForm.password,
imapHost: imapForm.imapHost || undefined,
imapPort: imapForm.imapPort || 993,
imapSecure: imapForm.imapSecure,
})
if (res.error) {
const msg = res.error.message || 'Connection failed'
showMessage('error', msg.includes('credentials') || msg.includes('auth') || msg.includes('password') ? 'Login failed check email and password' : msg)
setImapConnecting(false)
return
}
const list = await api.getEmailAccounts(user.$id)
setAccounts(list.data ?? [])
setShowImapForm(false)
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
showMessage('success', 'IMAP account connected')
setImapConnecting(false)
}
const handleAddVip = () => {
if (!newVipEmail.trim() || !newVipEmail.includes('@')) return
@@ -535,14 +576,18 @@ export function Settings() {
}
}
const tabs = [
{ id: 'profile' as TabType, label: 'Profile', icon: User },
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock },
]
const tabs = useMemo(() => {
const base = [
{ id: 'profile' as TabType, label: 'Profile', icon: User },
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
...(isAdmin ? [{ id: 'name-labels' as TabType, label: 'Name Labels (Team)', icon: Users }] : []),
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock },
]
return base
}, [isAdmin])
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
@@ -853,13 +898,13 @@ export function Settings() {
<div key={account.id} className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : 'bg-blue-100 dark:bg-blue-900/50'
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
}`}>
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'}`} />
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'}`} />
</div>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
</div>
</div>
<div className="flex items-center gap-3">
@@ -928,7 +973,100 @@ export function Settings() {
<p className="text-sm text-slate-500 dark:text-slate-400">Connect Microsoft account</p>
</div>
</button>
<button
type="button"
onClick={() => setShowImapForm(!showImapForm)}
className="flex items-center gap-4 p-4 border-2 border-slate-200 dark:border-slate-700 rounded-xl hover:border-slate-300 dark:hover:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-all"
>
<div className="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
<Mail className="w-6 h-6 text-slate-600 dark:text-slate-400" />
</div>
<div className="text-left">
<p className="font-semibold text-slate-900 dark:text-slate-100">IMAP / Other</p>
<p className="text-sm text-slate-500 dark:text-slate-400">Porkbun, Nextcloud Mail, or any IMAP</p>
</div>
</button>
</div>
{showImapForm && (
<form onSubmit={handleConnectImap} className="mt-6 p-4 border border-slate-200 dark:border-slate-700 rounded-xl bg-slate-50 dark:bg-slate-800/50 space-y-4">
<div>
<label htmlFor="imap-email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
<Input
id="imap-email"
type="email"
placeholder="you@example.com"
value={imapForm.email}
onChange={(e) => setImapForm((f) => ({ ...f, email: e.target.value }))}
required
autoComplete="email"
className="bg-white dark:bg-slate-900"
/>
</div>
<div>
<label htmlFor="imap-password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Password / App password</label>
<Input
id="imap-password"
type="password"
placeholder="••••••••"
value={imapForm.password}
onChange={(e) => setImapForm((f) => ({ ...f, password: e.target.value }))}
required
autoComplete="current-password"
className="bg-white dark:bg-slate-900"
/>
</div>
<details className="text-sm">
<summary className="cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200">Advanced (host, port, SSL)</summary>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label htmlFor="imap-host" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">IMAP host</label>
<Input
id="imap-host"
type="text"
placeholder="imap.porkbun.com"
value={imapForm.imapHost}
onChange={(e) => setImapForm((f) => ({ ...f, imapHost: e.target.value }))}
className="bg-white dark:bg-slate-900 text-sm"
/>
</div>
<div>
<label htmlFor="imap-port" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Port</label>
<Input
id="imap-port"
type="number"
min={1}
max={65535}
value={imapForm.imapPort}
onChange={(e) => setImapForm((f) => ({ ...f, imapPort: Number(e.target.value) || 993 }))}
className="bg-white dark:bg-slate-900 text-sm"
/>
</div>
<div className="flex items-end gap-2 pb-2">
<label className="flex items-center gap-2 cursor-pointer text-slate-600 dark:text-slate-400">
<input
type="checkbox"
checked={imapForm.imapSecure}
onChange={(e) => setImapForm((f) => ({ ...f, imapSecure: e.target.checked }))}
className="rounded border-slate-300 dark:border-slate-600"
/>
Use SSL
</label>
</div>
</div>
</details>
<div className="flex gap-2">
<Button type="submit" disabled={imapConnecting}>
{imapConnecting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
Connect IMAP
</Button>
<Button type="button" variant="outline" onClick={() => { setShowImapForm(false); setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }); }}>
Cancel
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</div>
@@ -2045,6 +2183,226 @@ export function Settings() {
{editingLabel?.id ? 'Save Changes' : 'Create Label'}
</Button>
</SidePanelFooter>
</SidePanelContent>
</SidePanel>
</div>
)}
{activeTab === 'name-labels' && isAdmin && (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary-500 dark:text-primary-400" />
<CardTitle>Name Labels (Team)</CardTitle>
</div>
<CardDescription>
Personal labels for each team member. The AI will assign emails to a worker when they are clearly for that person (e.g. &quot;für Max&quot;, &quot;an Anna&quot;, subject/body mentions).
</CardDescription>
</CardHeader>
<CardContent>
{nameLabels.length > 0 ? (
<div className="space-y-3">
{nameLabels.map((label) => (
<div
key={label.id || label.name}
className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50"
>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{label.name}</p>
{label.email && (
<p className="text-sm text-slate-500 dark:text-slate-400">{label.email}</p>
)}
{label.keywords?.length ? (
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
Keywords: {label.keywords.join(', ')}
</p>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setEditingNameLabel({ ...label })
setShowNameLabelPanel(true)
}}
>
<Edit2 className="w-4 h-4 mr-1" />
Edit
</Button>
<button
onClick={async () => {
if (!user?.$id || !label.id) return
try {
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled })
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
showMessage('success', 'Label updated!')
} catch {
showMessage('error', 'Failed to update label')
}
}}
className={`w-10 h-6 rounded-full transition-colors ${label.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
title={label.enabled ? 'Disable' : 'Enable'}
>
<div className={`w-4 h-4 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-1 ${
label.enabled ? 'translate-x-4' : 'translate-x-0'
}`} />
</button>
<Button
variant="ghost"
size="icon"
onClick={async () => {
if (!user?.$id || !label.id) return
if (!confirm('Delete this name label?')) return
try {
await api.deleteNameLabel(user.$id, user.email, label.id)
setNameLabels(nameLabels.filter(l => l.id !== label.id))
showMessage('success', 'Label deleted!')
} catch {
showMessage('error', 'Failed to delete label')
}
}}
>
<Trash2 className="w-4 h-4 text-red-500 dark:text-red-400" />
</Button>
</div>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={() => {
setEditingNameLabel({ name: '', enabled: true })
setShowNameLabelPanel(true)
}}
>
<Plus className="w-4 h-4 mr-2" />
Add team member
</Button>
</div>
) : (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p className="font-medium">No name labels yet</p>
<p className="text-sm mt-1">Add team members so the AI can assign emails to the right person</p>
<Button
variant="secondary"
size="sm"
className="mt-4"
onClick={() => {
setEditingNameLabel({ name: '', enabled: true })
setShowNameLabelPanel(true)
}}
>
<Plus className="w-4 h-4 mr-2" />
Add team member
</Button>
</div>
)}
</CardContent>
</Card>
{/* Name Label Editor Side Panel */}
<SidePanel open={showNameLabelPanel} onOpenChange={setShowNameLabelPanel}>
<SidePanelContent>
<SidePanelHeader>
<SidePanelCloseButton />
<SidePanelTitle>
{editingNameLabel?.id ? 'Edit Name Label' : 'Add Team Member'}
</SidePanelTitle>
<SidePanelDescription>
{editingNameLabel?.id
? 'Update the name label'
: 'Add a team member. The AI will assign emails to this person when they are clearly for them (e.g. &quot;für Max&quot;, subject mentions).'}
</SidePanelDescription>
</SidePanelHeader>
{editingNameLabel && (
<SidePanelBody>
<div className="space-y-6">
<div>
<Label htmlFor="namelabel-name">Name</Label>
<Input
id="namelabel-name"
placeholder="e.g. Max, Anna"
value={editingNameLabel.name}
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, name: e.target.value })}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="namelabel-email">Email (optional)</Label>
<Input
id="namelabel-email"
type="email"
placeholder="max@company.com"
value={editingNameLabel.email || ''}
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, email: e.target.value || undefined })}
className="mt-2"
/>
</div>
<div>
<Label htmlFor="namelabel-keywords">Keywords (optional, comma-separated)</Label>
<Input
id="namelabel-keywords"
placeholder="für Max, an Max, Max bitte"
value={(editingNameLabel.keywords || []).join(', ')}
onChange={(e) => setEditingNameLabel({
...editingNameLabel,
keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean),
})}
className="mt-2"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Hints for the AI to recognize emails for this person</p>
</div>
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<div>
<Label className="font-medium">Enabled</Label>
<p className="text-sm text-slate-500 dark:text-slate-400">This label will be used when sorting</p>
</div>
<button
onClick={() => setEditingNameLabel({ ...editingNameLabel, enabled: !editingNameLabel.enabled })}
className={`w-12 h-6 rounded-full transition-colors ${editingNameLabel.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
>
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
editingNameLabel.enabled ? 'translate-x-6' : 'translate-x-0'
}`} />
</button>
</div>
</div>
</SidePanelBody>
)}
<SidePanelFooter>
<Button variant="secondary" onClick={() => { setShowNameLabelPanel(false); setEditingNameLabel(null) }}>
Cancel
</Button>
<Button
onClick={async () => {
if (!user?.$id || !editingNameLabel?.name?.trim()) {
showMessage('error', 'Please enter a name')
return
}
try {
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
if (saved.data) {
if (editingNameLabel.id) {
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
showMessage('success', 'Label updated!')
} else {
setNameLabels([...nameLabels, saved.data])
showMessage('success', 'Label created!')
}
setShowNameLabelPanel(false)
setEditingNameLabel(null)
}
} catch {
showMessage('error', editingNameLabel.id ? 'Failed to update' : 'Failed to create')
}
}}
>
{editingNameLabel?.id ? 'Save Changes' : 'Add'}
</Button>
</SidePanelFooter>
</SidePanelContent>
</SidePanel>
</div>

View File

@@ -464,6 +464,11 @@ export function Setup() {
<ChevronRight className="w-5 h-5 text-slate-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all" />
</button>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-4">
Using Porkbun, Nextcloud Mail, or another IMAP provider?{' '}
<Link to="/settings?tab=accounts" className="text-primary-600 dark:text-primary-400 hover:underline">Add your account in Settings → Accounts</Link>.
</p>
</div>
<div className="mt-10 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl max-w-lg mx-auto">

View File

@@ -66,6 +66,15 @@ export interface CompanyLabel {
category?: string
}
/** Name label = personal label per worker (admin only). AI assigns emails to a worker when clearly for them. */
export interface NameLabel {
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}
export interface CategoryInfo {
key: string
name: string