Merge pull request 'Webklarintegrierung' (#1) from Webklarintegrierung into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-01-31 14:02:34 +00:00
47 changed files with 2652 additions and 460 deletions

View File

@@ -24,6 +24,12 @@ PRODUCT_CURRENCY=eur
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Gitea Webhook (Deployment)
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
GITEA_WEBHOOK_SECRET=your_webhook_secret_here
# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet)
# GITEA_WEBHOOK_AUTH_TOKEN=
# Server Configuration # Server Configuration
PORT=3000 PORT=3000
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000

View File

@@ -1,11 +1,11 @@
{ {
"name": "client", "name": "emailsorter-client",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "client", "name": "emailsorter-client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",

View File

@@ -17,39 +17,41 @@ const stepLabels: Record<string, string> = {
'completed': 'Completed', 'completed': 'Completed',
} }
const stepOrder = ['connect', 'first_rule', 'see_results', 'auto_schedule']
const stepOrderShort = ['connect', 'see_results']
export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) { export function OnboardingProgress({ currentStep, completedSteps, totalSteps, onSkip }: OnboardingProgressProps) {
const stepIndex = ['connect', 'first_rule', 'see_results', 'auto_schedule'].indexOf(currentStep) const steps = totalSteps === 2 ? stepOrderShort : stepOrder
const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 0 const stepIndex = steps.indexOf(currentStep)
const progress = totalSteps > 0 ? (completedSteps.length / totalSteps) * 100 : 0 const currentStepNumber = stepIndex >= 0 ? stepIndex + 1 : 1
const progress = totalSteps > 0 ? (completedSteps.filter(s => steps.includes(s)).length / totalSteps) * 100 : 0
if (currentStep === 'completed' || currentStep === 'not_started') { if (currentStep === 'completed' || currentStep === 'not_started') {
return null return null
} }
return ( return (
<div className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<p className="text-sm font-semibold text-slate-900">Getting started</p> <p className="text-sm font-semibold text-slate-900 dark:text-slate-100">Getting started</p>
<p className="text-xs text-slate-500">Step {currentStepNumber} of {totalSteps}</p> <p className="text-xs text-slate-500 dark:text-slate-400">Step {currentStepNumber} of {totalSteps}</p>
</div> </div>
<Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700"> <Button variant="ghost" size="sm" onClick={onSkip} className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300">
<X className="w-4 h-4 mr-1" /> <X className="w-4 h-4 mr-1" />
Skip I&apos;ll do this later
</Button> </Button>
</div> </div>
{/* Progress bar */} <div className="w-full h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden mb-2">
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-2">
<div <div
className="h-full bg-primary-500 transition-all duration-300" className="h-full bg-primary-500 transition-all duration-300"
style={{ width: `${progress}%` }} style={{ width: `${Math.min(100, progress)}%` }}
/> />
</div> </div>
{/* Step indicators */} <div className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-2 text-xs text-slate-500"> {steps.map((step, idx) => {
{['connect', 'first_rule', 'see_results', 'auto_schedule'].map((step, idx) => {
const isCompleted = completedSteps.includes(step) const isCompleted = completedSteps.includes(step)
const isCurrent = currentStep === step const isCurrent = currentStep === step
@@ -59,8 +61,8 @@ export function OnboardingProgress({ currentStep, completedSteps, totalSteps, on
isCompleted isCompleted
? 'bg-green-500 text-white' ? 'bg-green-500 text-white'
: isCurrent : isCurrent
? 'bg-primary-500 text-white ring-2 ring-primary-200' ? 'bg-primary-500 text-white ring-2 ring-primary-200 dark:ring-primary-800'
: 'bg-slate-200 text-slate-400' : 'bg-slate-200 dark:bg-slate-600 text-slate-400'
}`}> }`}>
{isCompleted ? ( {isCompleted ? (
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
@@ -69,13 +71,13 @@ export function OnboardingProgress({ currentStep, completedSteps, totalSteps, on
)} )}
</div> </div>
<span className={`truncate hidden sm:inline ${ <span className={`truncate hidden sm:inline ${
isCurrent ? 'text-slate-900 font-medium' : '' isCurrent ? 'text-slate-900 dark:text-slate-100 font-medium' : ''
}`}> }`}>
{stepLabels[step] || step} {stepLabels[step] || (step === 'see_results' ? 'Done' : step)}
</span> </span>
{idx < 3 && ( {idx < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-1 ${ <div className={`flex-1 h-0.5 mx-1 ${
isCompleted ? 'bg-green-500' : 'bg-slate-200' isCompleted ? 'bg-green-500' : 'bg-slate-200 dark:bg-slate-600'
}`} /> }`} />
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { X, Sparkles, Zap, Infinity } from 'lucide-react' import { X, Sparkles, Zap, Infinity as InfinityIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { trackUpgradeClicked } from '@/lib/analytics' import { trackUpgradeClicked } from '@/lib/analytics'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
@@ -97,7 +97,7 @@ export function UpgradePrompt({
onClick={handleUpgrade} onClick={handleUpgrade}
className="flex-1 bg-primary-600 hover:bg-primary-700" className="flex-1 bg-primary-600 hover:bg-primary-700"
> >
<Infinity className="w-4 h-4 mr-2" /> <InfinityIcon className="w-4 h-4 mr-2" />
Upgrade Upgrade
</Button> </Button>
<Button <Button

View File

@@ -1,119 +1,66 @@
import { useState } from 'react'
import { ChevronDown, HelpCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
const faqs = [ const faqs = [
{ {
question: "Are my emails secure?", question: "Why not just use Gmail filters?",
answer: "Yes! We use OAuth we never see your password. Content is only analyzed briefly, never stored." answer: "Gmail filters need rules you write (sender, keywords). We read the email and put it in Lead, Client, or Noise — no rules."
}, },
{ {
question: "Which email providers work?", question: "What happens to my emails?",
answer: "Gmail and Outlook. More coming soon." answer: "We only read headers and a short snippet to choose the label. We don't store your mail or use it for ads. Disconnect and we stop."
}, },
{ {
question: "Can I create custom rules?", question: "Can this mess up my inbox?",
answer: "Absolutely! You can set VIP contacts and define custom categories." answer: "We only add labels or move to folders. We don't delete. Disconnect and nothing stays changed."
}, },
{ {
question: "What about old emails?", question: "Do you need my password?",
answer: "The last 30 days are analyzed. You decide if they should be sorted too." answer: "No. You sign in with Google or Microsoft. We never see or store your password. You can revoke access anytime."
}, },
{ {
question: "Can I cancel anytime?", question: "What if I don't like it?",
answer: "Yes, with one click. No tricks, no long commitments." answer: "Cancel anytime. No contract. Your data is yours; disconnect and we stop. Free trial, no card."
},
{
question: "Do I need a credit card?",
answer: "No, the 14-day trial is completely free."
},
{
question: "Does it work on mobile?",
answer: "Yes! Sorting runs on our servers works in any email app."
},
{
question: "What if the AI sorts wrong?",
answer: "Just correct it. The AI learns and gets better over time."
}, },
] ]
export function FAQ() { export function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(0)
return ( return (
<section id="faq" className="py-24 bg-slate-50 dark:bg-slate-900"> <section id="faq" className="py-24 bg-slate-50 dark:bg-slate-900">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */} <div className="text-center mb-12">
<div className="text-center mb-16"> <h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-2">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-primary-100 dark:bg-primary-900/30 mb-6"> Questions we get a lot
<HelpCircle className="w-8 h-8 text-primary-600 dark:text-primary-400" />
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
FAQ
</h2> </h2>
<p className="text-lg text-slate-600 dark:text-slate-400"> <p className="text-slate-600 dark:text-slate-400">
Quick answers to common questions. Straight answers. No fluff.
</p> </p>
</div> </div>
{/* FAQ items */} <div className="space-y-12">
<div className="space-y-3">
{faqs.map((faq, index) => ( {faqs.map((faq, index) => (
<FAQItem <div
key={index} key={index}
question={faq.question} className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-10 items-baseline"
answer={faq.answer} >
isOpen={openIndex === index} <p className="text-lg md:text-xl font-semibold text-slate-900 dark:text-slate-100">
onClick={() => setOpenIndex(openIndex === index ? null : index)} {faq.question}
/> </p>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{faq.answer}
</p>
</div>
))} ))}
</div> </div>
{/* Contact CTA */} <p className="mt-10 text-center text-sm text-slate-600 dark:text-slate-400">
<div className="mt-12 text-center p-6 bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700"> Still unsure?{' '}
<p className="text-slate-600 dark:text-slate-400 mb-2">Still have questions?</p> <a
<a href="mailto:support@emailsorter.webklar.com"
href="mailto:support@emailsorter.com" className="text-slate-700 dark:text-slate-300 hover:underline"
className="text-primary-600 dark:text-primary-400 font-semibold hover:text-primary-700 dark:hover:text-primary-300"
> >
Contact us Email us we reply fast
</a> </a>
</div> .
</p>
</div> </div>
</section> </section>
) )
} }
interface FAQItemProps {
question: string
answer: string
isOpen: boolean
onClick: () => void
}
function FAQItem({ question, answer, isOpen, onClick }: FAQItemProps) {
return (
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<button
className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
onClick={onClick}
>
<span className="font-semibold text-slate-900 dark:text-slate-100 pr-4">{question}</span>
<ChevronDown
className={cn(
"w-5 h-5 text-slate-400 dark:text-slate-500 transition-transform duration-200 flex-shrink-0",
isOpen && "rotate-180"
)}
/>
</button>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isOpen ? "max-h-40" : "max-h-0"
)}
>
<p className="px-6 pb-4 text-slate-600 dark:text-slate-400">{answer}</p>
</div>
</div>
)
}

View File

@@ -11,41 +11,41 @@ import {
const features = [ const features = [
{ {
icon: Inbox, icon: Inbox,
title: "Stop drowning in emails", title: "Categories, not chaos",
description: "Clear inbox, less stress. Automatically sort newsletters, promotions, and social updates away from what matters.", description: "Leads, clients, invoices, newsletters — sorted into folders. Your inbox shows what pays first.",
color: "from-violet-500 to-purple-600", color: "from-violet-500 to-purple-600",
highlight: true, highlight: true,
}, },
{ {
icon: Zap, icon: Zap,
title: "One-click smart rules", title: "One click to sort",
description: "AI suggests, you approve. Create smart rules in seconds and apply them with one click.", description: "Connect your inbox, click Sort Now. No rules to write. We read and categorize; you review.",
color: "from-amber-500 to-orange-600", color: "from-amber-500 to-orange-600",
highlight: true, highlight: true,
}, },
{ {
icon: Settings, icon: Settings,
title: "Automation that keeps working", title: "Runs when you want",
description: "Set it and forget it. Your inbox stays organized automatically, day after day.", description: "Sort on demand or set a schedule. Your inbox stays organized without you touching it.",
color: "from-blue-500 to-cyan-600", color: "from-blue-500 to-cyan-600",
highlight: true, highlight: true,
}, },
{ {
icon: Brain, icon: Brain,
title: "AI-powered smart sorting", title: "Content-aware sorting",
description: "Our AI automatically recognizes whether an email is an invoice, newsletter, or important message.", description: "We look at sender, subject, and a short snippet to decide the category. No keyword lists.",
color: "from-green-500 to-emerald-600" color: "from-green-500 to-emerald-600"
}, },
{ {
icon: Shield, icon: Shield,
title: "GDPR compliant", title: "Minimal data",
description: "Your data stays secure. We only read email headers and metadata for sorting.", description: "We only read what we need to categorize. No storing email body or attachments. GDPR compliant.",
color: "from-pink-500 to-rose-600" color: "from-pink-500 to-rose-600"
}, },
{ {
icon: Clock, icon: Clock,
title: "Save time", title: "Less time on triage",
description: "Average 2 hours per week less on email organization. More time for what matters.", description: "Spend less time deciding what's important. Inbox shows clients and leads first.",
color: "from-indigo-500 to-blue-600" color: "from-indigo-500 to-blue-600"
}, },
] ]
@@ -57,14 +57,10 @@ export function Features() {
{/* Section header */} {/* Section header */}
<div className="text-center mb-16"> <div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4"> <h2 className="text-3xl sm:text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Everything you need for{' '} What it does
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-accent-500">
Inbox Zero
</span>
</h2> </h2>
<p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto"> <p className="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
EmailSorter combines AI technology with proven email management methods Sort incoming mail into categories so your inbox shows what matters first. No rules to write.
for maximum productivity.
</p> </p>
</div> </div>

View File

@@ -17,7 +17,7 @@ export function Footer() {
</span> </span>
</Link> </Link>
<p className="text-sm text-slate-400 mb-6"> <p className="text-sm text-slate-400 mb-6">
AI-powered email sorting for more productivity and less stress. Email sorting for freelancers and small teams. Gmail & Outlook.
</p> </p>
{/* Social links */} {/* Social links */}
<div className="flex gap-4"> <div className="flex gap-4">
@@ -70,26 +70,19 @@ export function Footer() {
FAQ FAQ
</button> </button>
</li> </li>
<li>
<a href="#" className="hover:text-white transition-colors">
Roadmap
</a>
</li>
</ul> </ul>
</div> </div>
{/* Company */} {/* Contact */}
<div> <div>
<h4 className="font-semibold text-white mb-4">Company</h4> <h4 className="font-semibold text-white mb-4">Contact</h4>
<ul className="space-y-3"> <ul className="space-y-3">
<li> <li>
<a <a
href="https://webklar.com" href="mailto:support@emailsorter.webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
> >
About us support@emailsorter.webklar.com
</a> </a>
</li> </li>
<li> <li>
@@ -99,25 +92,7 @@ export function Footer() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-white transition-colors" className="hover:text-white transition-colors"
> >
Blog webklar.com
</a>
</li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
Careers
</a>
</li>
<li>
<a
href="mailto:support@webklar.com"
className="hover:text-white transition-colors"
>
Contact
</a> </a>
</li> </li>
</ul> </ul>
@@ -142,16 +117,6 @@ export function Footer() {
Impressum Impressum
</Link> </Link>
</li> </li>
<li>
<a
href="https://webklar.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
webklar.com
</a>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -160,10 +125,7 @@ export function Footer() {
<div className="mt-12 pt-8 border-t border-slate-800"> <div className="mt-12 pt-8 border-t border-slate-800">
<div className="flex flex-col md:flex-row justify-between items-center gap-4 mb-4"> <div className="flex flex-col md:flex-row justify-between items-center gap-4 mb-4">
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
© {new Date().getFullYear()} EmailSorter. All rights reserved. © {new Date().getFullYear()} EmailSorter
</p>
<p className="text-sm text-slate-500">
Made with
</p> </p>
</div> </div>
{/* webklar.com Verweis */} {/* webklar.com Verweis */}

View File

@@ -1,8 +1,9 @@
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { captureUTMParams } from '@/lib/analytics' import { captureUTMParams } from '@/lib/analytics'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { ArrowRight, Mail, Inbox, Sparkles, Check, Zap } from 'lucide-react' import { ArrowRight, Sparkles, Check } from 'lucide-react'
export function Hero() { export function Hero() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -33,47 +34,46 @@ export function Hero() {
<div className="text-center lg:text-left"> <div className="text-center lg:text-left">
<Badge className="mb-6 bg-primary-500/20 text-primary-200 border-primary-400/30"> <Badge className="mb-6 bg-primary-500/20 text-primary-200 border-primary-400/30">
<Sparkles className="w-3 h-3 mr-1" /> <Sparkles className="w-3 h-3 mr-1" />
AI-powered email sorting For freelancers & small teams
</Badge> </Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6"> <h1 className="text-4xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-tight mb-6">
Clean inbox automatically Leads, clients, spam
<br /> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-primary-300">
in minutes. sorted automatically.
</span> </span>
</h1> </h1>
<p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0"> <p className="text-lg sm:text-xl text-slate-300 mb-8 max-w-xl mx-auto lg:mx-0">
Create smart rules, apply in one click, keep it clean with automation. Connect Gmail or Outlook. We put newsletters, promos, and noise in folders so your inbox stays for what pays.
Stop drowning in emails and start focusing on what matters.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-8"> <div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start mb-4">
<Button
size="xl"
onClick={() => navigate('/setup?demo=true')}
className="group bg-accent-500 hover:bg-accent-600"
>
<Sparkles className="w-5 h-5 mr-2" />
Try Demo
</Button>
<Button <Button
size="xl" size="xl"
onClick={handleCTAClick} onClick={handleCTAClick}
variant="outline" className="group bg-accent-500 hover:bg-accent-600"
className="bg-white/10 border-white/20 text-white hover:bg-white/20 group"
> >
Connect inbox Try it free
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button> </Button>
</div> </div>
<p className="text-sm text-slate-400 mb-8">
<button
type="button"
onClick={() => navigate('/setup?demo=true')}
className="underline hover:text-slate-300 transition-colors"
>
Or try a 30-second demo first
</button>
</p>
{/* Trust badges */} {/* Trust badges */}
<div className="flex flex-wrap gap-6 justify-center lg:justify-start text-slate-400 text-sm"> <div className="flex flex-wrap gap-6 justify-center lg:justify-start text-slate-400 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" /> <Check className="w-4 h-4 text-accent-400" />
No credit card required No credit card
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Check className="w-4 h-4 text-accent-400" /> <Check className="w-4 h-4 text-accent-400" />
@@ -86,59 +86,22 @@ export function Hero() {
</div> </div>
</div> </div>
{/* Right side - Visual */} {/* Right side - Inbox visual (product screenshot feel) */}
<div className="relative hidden lg:block"> <div className="relative hidden lg:block">
<div className="relative"> <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 max-w-md overflow-hidden">
{/* Main card */} <div className="border-b border-slate-200 dark:border-slate-700 px-4 py-2.5">
<div className="bg-white/10 backdrop-blur-xl rounded-3xl border border-white/20 p-6 shadow-2xl animate-float"> <span className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Inbox</span>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Inbox className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-white">Your Inbox</h3>
<p className="text-sm text-slate-400">Auto-sorted</p>
</div>
</div>
{/* Email categories preview */}
<div className="space-y-3">
<EmailPreview
category="Important"
color="bg-red-500"
sender="John Smith"
subject="Meeting tomorrow at 10"
delay="stagger-1"
/>
<EmailPreview
category="Invoice"
color="bg-green-500"
sender="Amazon"
subject="Invoice for order #12345"
delay="stagger-2"
/>
<EmailPreview
category="Newsletter"
color="bg-purple-500"
sender="Tech Daily"
subject="Latest AI trends"
delay="stagger-3"
/>
<EmailPreview
category="Social"
color="bg-cyan-500"
sender="LinkedIn"
subject="3 new connection requests"
delay="stagger-4"
/>
</div>
</div> </div>
<div className="divide-y divide-slate-100 dark:divide-slate-800">
{/* Floating badge */} <InboxRow sender="Sarah Chen" subject="Re: Project quote" label="Lead" isFocal />
<div className="absolute -right-4 top-1/4 bg-accent-500 text-white px-4 py-2 rounded-full text-sm font-semibold shadow-lg animate-pulse-glow"> <InboxRow sender="Mike, Acme Inc" subject="Invoice #8821" label="Client" />
<Zap className="w-4 h-4 inline mr-1" /> <InboxRow sender="Newsletter" subject="Your weekly digest" label="Noise" />
AI sorting <InboxRow sender="Support" subject="Your ticket #443" label="Client" />
<InboxRow sender="Promo" subject="20% off this week" label="Noise" />
</div> </div>
<p className="text-xs text-slate-400 dark:text-slate-500 px-4 py-3 border-t border-slate-100 dark:border-slate-800">
This happens automatically on new emails.
</p>
</div> </div>
</div> </div>
</div> </div>
@@ -154,26 +117,39 @@ export function Hero() {
) )
} }
interface EmailPreviewProps { type InboxLabel = 'Lead' | 'Client' | 'Noise'
category: string
color: string const labelClass: Record<InboxLabel, string> = {
sender: string Lead: 'text-primary-600 dark:text-primary-500',
subject: string Client: 'text-slate-600 dark:text-slate-600',
delay: string Noise: 'text-slate-400 dark:text-slate-500',
} }
function EmailPreview({ category, color, sender, subject, delay }: EmailPreviewProps) { interface InboxRowProps {
sender: string
subject: string
label: InboxLabel
isFocal?: boolean
}
function InboxRow({ sender, subject, label, isFocal = false }: InboxRowProps) {
return ( return (
<div className={`flex items-center gap-3 bg-white/5 rounded-xl p-3 border border-white/10 opacity-0 animate-[fadeIn_0.5s_ease-out_forwards] ${delay}`}> <div
<div className={`w-2 h-10 rounded-full ${color}`} /> className={cn(
"flex items-center gap-4 px-4 py-2.5",
isFocal && "border-l-2 border-l-primary-500 bg-slate-50/80 dark:bg-slate-800/50"
)}
>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5"> <p className={cn(
<span className="text-sm font-medium text-white truncate">{sender}</span> "truncate",
<span className={`text-xs px-2 py-0.5 rounded ${color} text-white`}>{category}</span> isFocal ? "text-sm font-semibold text-slate-900 dark:text-slate-100" : "text-sm font-medium text-slate-600 dark:text-slate-400"
</div> )}>
<p className="text-sm text-slate-400 truncate">{subject}</p> {sender}
</p>
<p className="text-xs text-slate-500 dark:text-slate-500 truncate mt-0.5">{subject}</p>
</div> </div>
<Mail className="w-4 h-4 text-slate-500 flex-shrink-0" /> <span className={cn("text-xs flex-shrink-0", labelClass[label])}>{label}</span>
</div> </div>
) )
} }

View File

@@ -17,19 +17,19 @@ const steps = [
icon: Link2, icon: Link2,
step: "02", step: "02",
title: "Connect email", title: "Connect email",
description: "Connect Gmail or Outlook with one click. Secure OAuth authentication.", description: "Sign in with Google or Microsoft. We never see your password.",
}, },
{ {
icon: Sparkles, icon: Sparkles,
step: "03", step: "03",
title: "AI analyzes", title: "We categorize",
description: "Our AI learns your email patterns and creates personalized sorting rules.", description: "We read sender and subject, put each email in a category. No rules to write.",
}, },
{ {
icon: PartyPopper, icon: PartyPopper,
step: "04", step: "04",
title: "Enjoy Inbox Zero", title: "Inbox stays clean",
description: "Sit back and enjoy a clean inbox automatically.", description: "Newsletters and promos go to folders. Your inbox shows what matters first.",
}, },
] ]

View File

@@ -82,7 +82,7 @@ export function Navbar() {
Sign in Sign in
</Button> </Button>
<Button onClick={() => navigate('/register')}> <Button onClick={() => navigate('/register')}>
Get started free Try it free
</Button> </Button>
</> </>
)} )}
@@ -146,7 +146,7 @@ export function Navbar() {
Sign in Sign in
</Button> </Button>
<Button className="w-full h-11" onClick={() => navigate('/register')}> <Button className="w-full h-11" onClick={() => navigate('/register')}>
Get started free Try it free
</Button> </Button>
</> </>
)} )}

View File

@@ -1,67 +1,51 @@
import { CheckCircle2, Clock, Brain, Shield } from 'lucide-react' import { Code2, Users, Zap } from 'lucide-react'
const benefits = [ const items = [
{ {
icon: Clock, icon: Code2,
title: "Save 2+ hours/week", title: "Built in public",
description: "Less time sorting emails, more time for important tasks.", description: "We ship updates and share progress openly. No hype, no fake traction.",
}, },
{ {
icon: Brain, icon: Users,
title: "AI does it automatically", title: "Early users",
description: "Set up once, then everything runs by itself.", description: "We're in beta. Feedback from freelancers and small teams shapes the product.",
}, },
{ {
icon: Shield, icon: Zap,
title: "Privacy first", title: "Simple setup",
description: "Your emails stay private. We don't store any content.", description: "Connect Gmail or Outlook, click Sort. No long onboarding or sales call.",
},
{
icon: CheckCircle2,
title: "Easy to use",
description: "No learning curve. Ready to go in 2 minutes.",
}, },
] ]
export function Testimonials() { export function Testimonials() {
return ( return (
<section className="py-20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"> <section className="py-20 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Section header */}
<div className="text-center mb-12"> <div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4"> <h2 className="text-2xl sm:text-3xl font-bold text-white mb-2">
Why EmailSorter? Honest context
</h2> </h2>
<p className="text-lg text-slate-300 max-w-2xl mx-auto"> <p className="text-slate-400 text-sm sm:text-base">
No more email chaos. Focus on what matters. We're a small product. Here's how we work.
</p> </p>
</div> </div>
{/* Benefits grid */} <div className="grid md:grid-cols-3 gap-6">
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6"> {items.map((item, index) => (
{benefits.map((benefit, index) => ( <div
<BenefitCard key={index} {...benefit} /> key={index}
className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10"
>
<div className="w-10 h-10 rounded-lg bg-primary-500/20 flex items-center justify-center mb-4">
<item.icon className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-base font-semibold text-white mb-1">{item.title}</h3>
<p className="text-slate-400 text-sm">{item.description}</p>
</div>
))} ))}
</div> </div>
</div> </div>
</section> </section>
) )
} }
interface BenefitCardProps {
icon: React.ElementType
title: string
description: string
}
function BenefitCard({ icon: Icon, title, description }: BenefitCardProps) {
return (
<div className="bg-white/5 backdrop-blur-sm rounded-xl p-6 border border-white/10 hover:bg-white/10 transition-colors">
<div className="w-12 h-12 rounded-lg bg-primary-500/20 flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-slate-400 text-sm">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { Shield, Mail, Trash2 } from 'lucide-react'
export function TrustSection() {
return (
<section id="trust" className="py-16 sm:py-20 bg-white dark:bg-slate-900 border-y border-slate-200 dark:border-slate-800">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-slate-100 text-center mb-10">
Your data, in plain language
</h2>
<ul className="space-y-6">
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Mail className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">We only read what we need</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
We use sender, subject, and a short snippet to decide the category (e.g. newsletter vs client). We don&apos;t store your email body or attachments.
</p>
</div>
</li>
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Shield className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">No selling, no ads</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
Your email data is not used for advertising or sold to anyone. We run a paid product; our revenue comes from subscriptions, not your inbox.
</p>
</div>
</li>
<li className="flex gap-4 items-start">
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-800 flex items-center justify-center flex-shrink-0">
<Trash2 className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</div>
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 mb-0.5">You can leave anytime</p>
<p className="text-slate-600 dark:text-slate-400 text-sm">
Disconnect your account and we stop. Cancel your subscription with one click. No lock-in, no &quot;contact sales&quot; to leave.
</p>
</div>
</li>
</ul>
</div>
</section>
)
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"

View File

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useEffect, useState } from 'react' import React, { createContext, useContext, useEffect, useState } from 'react'
import { auth } from '@/lib/appwrite' import { auth } from '@/lib/appwrite'
import type { Models } from 'appwrite' import type { Models } from 'appwrite'

View File

@@ -17,7 +17,7 @@ export interface TrackingParams {
export interface ConversionEvent { export interface ConversionEvent {
type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected' | 'onboarding_step' | 'provider_connected' | 'demo_used' | 'suggested_rules_generated' | 'rule_created' | 'rules_applied' | 'limit_reached' | 'upgrade_clicked' | 'referral_shared' | 'sort_completed' | 'account_deleted' type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected' | 'onboarding_step' | 'provider_connected' | 'demo_used' | 'suggested_rules_generated' | 'rule_created' | 'rules_applied' | 'limit_reached' | 'upgrade_clicked' | 'referral_shared' | 'sort_completed' | 'account_deleted'
userId?: string userId?: string
metadata?: Record<string, any> metadata?: Record<string, unknown>
sessionId?: string sessionId?: string
} }

View File

@@ -56,7 +56,7 @@ export const api = {
return fetchApi<Array<{ return fetchApi<Array<{
id: string id: string
email: string email: string
provider: 'gmail' | 'outlook' provider: 'gmail' | 'outlook' | 'imap'
connected: boolean connected: boolean
lastSync?: string lastSync?: string
}>>(`/email/accounts?userId=${userId}`) }>>(`/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) { async disconnectEmailAccount(accountId: string, userId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, { return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
method: 'DELETE', method: 'DELETE',
@@ -105,7 +123,7 @@ export const api = {
name: string name: string
description: string description: string
confidence: number confidence: number
action: any action?: { name?: string }
}> }>
}>('/email/sort', { }>('/email/sort', {
method: 'POST', method: 'POST',
@@ -276,6 +294,7 @@ export const api = {
blockedSenders?: string[] blockedSenders?: string[]
customRules?: Array<{ condition: string; category: string }> customRules?: Array<{ condition: string; category: string }>
priorityTopics?: string[] priorityTopics?: string[]
companyLabels?: Array<{ name: string; condition?: string; category: string; enabled: boolean }>
}) { }) {
return fetchApi<{ success: boolean }>('/preferences', { return fetchApi<{ success: boolean }>('/preferences', {
method: 'POST', method: 'POST',
@@ -292,8 +311,8 @@ export const api = {
enabledCategories: string[] enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'> categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean autoDetectCompanies: boolean
cleanup?: any cleanup?: unknown
categoryAdvanced?: Record<string, any> categoryAdvanced?: Record<string, unknown>
version?: number version?: number
}>(`/preferences/ai-control?userId=${userId}`) }>(`/preferences/ai-control?userId=${userId}`)
}, },
@@ -302,8 +321,8 @@ export const api = {
enabledCategories?: string[] enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'> categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean autoDetectCompanies?: boolean
cleanup?: any cleanup?: unknown
categoryAdvanced?: Record<string, any> categoryAdvanced?: Record<string, unknown>
version?: number version?: number
}) { }) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', { return fetchApi<{ success: boolean }>('/preferences/ai-control', {
@@ -402,19 +421,62 @@ 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) // PRODUCTS & QUESTIONS (Legacy)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getProducts() { async getProducts() {
return fetchApi<any[]>('/products') return fetchApi<unknown[]>('/products')
}, },
async getQuestions(productSlug: string) { async getQuestions(productSlug: string) {
return fetchApi<any[]>(`/questions?productSlug=${productSlug}`) return fetchApi<unknown[]>(`/questions?productSlug=${productSlug}`)
}, },
async createSubmission(productSlug: string, answers: Record<string, any>) { async createSubmission(productSlug: string, answers: Record<string, unknown>) {
return fetchApi<{ submissionId: string }>('/submissions', { return fetchApi<{ submissionId: string }>('/submissions', {
method: 'POST', method: 'POST',
body: JSON.stringify({ productSlug, answers }), body: JSON.stringify({ productSlug, answers }),

View File

@@ -18,13 +18,9 @@ export { ID }
export const auth = { export const auth = {
// Create a new account // Create a new account
async register(email: string, password: string, name?: string) { async register(email: string, password: string, name?: string) {
try { const user = await account.create(ID.unique(), email, password, name)
const user = await account.create(ID.unique(), email, password, name) await this.login(email, password)
await this.login(email, password) return user
return user
} catch (error) {
throw error
}
}, },
// Login with email and password // Login with email and password

View File

@@ -17,9 +17,7 @@ import {
Zap, Zap,
BarChart3, BarChart3,
Users, Users,
Bell,
Shield, Shield,
HelpCircle,
Loader2, Loader2,
Check, Check,
AlertCircle, AlertCircle,
@@ -65,7 +63,7 @@ interface SortResult {
name: string name: string
description: string description: string
confidence: number confidence: number
action: any action?: { name?: string; email?: string; condition?: string; category?: string }
}> }>
} }
@@ -131,7 +129,7 @@ export function Dashboard() {
if (referralRes.data) setReferralCode(referralRes.data.referralCode) if (referralRes.data) setReferralCode(referralRes.data.referralCode)
} catch (err) { } catch (err) {
console.error('Error loading dashboard data:', err) console.error('Error loading dashboard data:', err)
setError('Failed to load data') setError('Couldnt load your data. Check your connection and refresh.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -139,7 +137,7 @@ export function Dashboard() {
const handleSortNow = async () => { const handleSortNow = async () => {
if (!user?.$id || accounts.length === 0) { if (!user?.$id || accounts.length === 0) {
setError('Please connect an email account first to start sorting.') setError('Connect your inbox first, then click Sort Now.')
return return
} }
@@ -178,7 +176,7 @@ export function Dashboard() {
} }
} catch (err) { } catch (err) {
console.error('Error sorting emails:', err) console.error('Error sorting emails:', err)
setError('Unable to sort emails right now. Please check your connection and try again.') setError('Something went wrong. Check your connection and try again.')
} finally { } finally {
setSorting(false) setSorting(false)
} }
@@ -248,13 +246,6 @@ export function Dashboard() {
</Link> </Link>
<div className="flex items-center gap-1.5 sm:gap-2 lg:gap-4"> <div className="flex items-center gap-1.5 sm:gap-2 lg:gap-4">
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
<Bell className="w-5 h-5 text-slate-500 dark:text-slate-400" />
</Button>
<Button variant="ghost" size="icon" className="hidden lg:flex h-9 w-9">
<HelpCircle className="w-5 h-5 text-slate-500 dark:text-slate-400" />
</Button>
<div className="hidden lg:block h-6 w-px bg-slate-200 dark:bg-slate-700" />
<Button <Button
variant="ghost" variant="ghost"
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
@@ -304,7 +295,7 @@ export function Dashboard() {
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
{accounts.length > 0 {accounts.length > 0
? `${accounts.length} account${accounts.length > 1 ? 's' : ''} connected` ? `${accounts.length} account${accounts.length > 1 ? 's' : ''} connected`
: 'Connect an account to get started'} : 'Connect Gmail or Outlook to sort your first emails.'}
</p> </p>
</div> </div>
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
@@ -315,7 +306,7 @@ export function Dashboard() {
aria-label="Connect email account" aria-label="Connect email account"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Connect Account Connect inbox
</Button> </Button>
) : ( ) : (
<Button <Button
@@ -359,6 +350,15 @@ export function Dashboard() {
</div> </div>
)} )}
{/* First-time hint: account connected, no sort yet */}
{!loading && accounts.length > 0 && !sortResult && !error && (
<div className="mb-4 p-4 bg-slate-100 dark:bg-slate-800/60 rounded-xl border border-slate-200 dark:border-slate-700">
<p className="text-sm text-slate-700 dark:text-slate-300">
Click <strong>Sort Now</strong> to categorize your inbox. Takes about 30 seconds. Nothing is deleted we only add labels or move mail to folders.
</p>
</div>
)}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start sm:items-center gap-2 text-red-700 dark:text-red-300"> <div className="mb-3 sm:mb-4 p-2.5 sm:p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start sm:items-center gap-2 text-red-700 dark:text-red-300">
@@ -378,7 +378,8 @@ export function Dashboard() {
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-10 h-10 animate-spin text-primary-500 dark:text-primary-400 mx-auto mb-4" /> <Loader2 className="w-10 h-10 animate-spin text-primary-500 dark:text-primary-400 mx-auto mb-4" />
<p className="text-slate-500 dark:text-slate-400">Loading dashboard...</p> <p className="text-slate-500 dark:text-slate-400">Loading your data...</p>
<p className="text-slate-400 dark:text-slate-500 text-sm mt-1">One moment.</p>
</div> </div>
</div> </div>
) : ( ) : (
@@ -394,9 +395,9 @@ export function Dashboard() {
<Sparkles className="w-5 h-5 text-white" /> <Sparkles className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
<CardTitle className="text-lg">AI-Assistent</CardTitle> <CardTitle className="text-lg">Email sorting</CardTitle>
<CardDescription className="text-sm"> <CardDescription className="text-sm">
Automatische E-Mail-Sortierung mit KI Categorize inbox leads, clients, newsletters
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -413,10 +414,10 @@ export function Dashboard() {
</div> </div>
<div> <div>
<p className={`${sortResult.isFirstRun ? 'text-lg' : 'text-sm'} font-bold text-green-800 dark:text-green-200`}> <p className={`${sortResult.isFirstRun ? 'text-lg' : 'text-sm'} font-bold text-green-800 dark:text-green-200`}>
{sortResult.isFirstRun ? '🎉 First sort complete!' : 'Sorting complete!'} {sortResult.isFirstRun ? 'Done. Your inbox is sorted.' : 'Sort complete.'}
</p> </p>
{sortResult.isFirstRun && ( {sortResult.isFirstRun && (
<p className="text-sm text-green-700 dark:text-green-300 mt-0.5">Your inbox is getting organized</p> <p className="text-sm text-green-700 dark:text-green-300 mt-0.5">Newsletters and promos are in folders. Check your inbox only important mail is left.</p>
)} )}
</div> </div>
</div> </div>
@@ -444,10 +445,10 @@ export function Dashboard() {
<div className="bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg"> <div className="bg-white/80 dark:bg-slate-800/80 p-3 rounded-lg">
<p className="text-xs text-green-600 dark:text-green-400 mb-1">Top category</p> <p className="text-xs text-green-600 dark:text-green-400 mb-1">Top category</p>
<p className="text-xl font-bold text-green-800 dark:text-green-200"> <p className="text-xl font-bold text-green-800 dark:text-green-200">
{Object.entries(sortResult.categories).sort(([_, a], [__, b]) => b - a)[0]?.[1] || 0} {Object.entries(sortResult.categories).sort(([, a], [, b]) => b - a)[0]?.[1] || 0}
</p> </p>
<p className="text-xs text-green-500 dark:text-green-400 mt-1"> <p className="text-xs text-green-500 dark:text-green-400 mt-1">
{formatCategoryName(Object.entries(sortResult.categories).sort(([_, a], [__, b]) => b - a)[0]?.[0] || '')} {formatCategoryName(Object.entries(sortResult.categories).sort(([, a], [, b]) => b - a)[0]?.[0] || '')}
</p> </p>
</div> </div>
)} )}
@@ -457,7 +458,7 @@ export function Dashboard() {
{sortResult.isFirstRun && sortResult.suggestedRules && sortResult.suggestedRules.length > 0 && ( {sortResult.isFirstRun && sortResult.suggestedRules && sortResult.suggestedRules.length > 0 && (
<div className="mt-4 pt-4 border-t border-green-200 dark:border-green-800"> <div className="mt-4 pt-4 border-t border-green-200 dark:border-green-800">
<div className="mb-3"> <div className="mb-3">
<p className="text-sm font-semibold text-green-900 dark:text-green-200">Smart suggestions for you</p> <p className="text-sm font-semibold text-green-900 dark:text-green-200">Suggestions for you</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5">Based on your email patterns</p> <p className="text-xs text-green-600 dark:text-green-400 mt-0.5">Based on your email patterns</p>
</div> </div>
<div className="space-y-2 mb-3"> <div className="space-y-2 mb-3">
@@ -479,19 +480,19 @@ export function Dashboard() {
setSorting(true) setSorting(true)
try { try {
const vipSenders = sortResult.suggestedRules const vipSenders = sortResult.suggestedRules
.filter(r => r.type === 'vip_sender' && r.action?.email) .filter((r): r is typeof r & { action: { email: string } } => r.type === 'vip_sender' && !!r.action?.email)
.map(r => r.action.email) .map(r => ({ email: r.action.email }))
const companyLabels = sortResult.suggestedRules const companyLabels = sortResult.suggestedRules
.filter(r => r.type === 'company_label' && r.action?.name) .filter((r): r is typeof r & { action: { name: string; condition?: string; category?: string } } => r.type === 'company_label' && !!r.action?.name)
.map(r => ({ .map(r => ({
name: r.action.name, name: r.action.name,
condition: r.action.condition, condition: r.action.condition ?? '',
category: r.action.category || 'promotions', category: r.action.category || 'promotions',
enabled: true, enabled: true,
})) }))
const updates: any = {} const updates: { vipSenders?: Array<{ email: string; name?: string }>; companyLabels?: Array<{ name: string; condition?: string; category: string; enabled: boolean }> } = {}
if (vipSenders.length > 0) { if (vipSenders.length > 0) {
updates.vipSenders = vipSenders updates.vipSenders = vipSenders
} }
@@ -502,10 +503,10 @@ export function Dashboard() {
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
await api.saveUserPreferences(user.$id, updates) await api.saveUserPreferences(user.$id, updates)
trackRulesApplied(user.$id, sortResult.suggestedRules.length) trackRulesApplied(user.$id, sortResult.suggestedRules.length)
showMessage('success', `Applied ${sortResult.suggestedRules.length} smart rules! Your inbox will stay organized.`) showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
setSortResult({ ...sortResult, suggestedRules: [] }) setSortResult({ ...sortResult, suggestedRules: [] })
} }
} catch (err) { } catch {
showMessage('error', 'Unable to apply rules right now. Please try again.') showMessage('error', 'Unable to apply rules right now. Please try again.')
} finally { } finally {
setSorting(false) setSorting(false)
@@ -756,7 +757,7 @@ export function Dashboard() {
<CardTitle className="text-base">Control Panel</CardTitle> <CardTitle className="text-base">Control Panel</CardTitle>
</div> </div>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
KI-Einstellungen und Kategorien verwalten Categories and rules
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -767,7 +768,7 @@ export function Dashboard() {
aria-label="Open Control Panel settings" aria-label="Open Control Panel settings"
> >
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Control Panel öffnen Open Control Panel
<ExternalLink className="w-3 h-3 ml-2" /> <ExternalLink className="w-3 h-3 ml-2" />
</Button> </Button>
</CardContent> </CardContent>
@@ -778,10 +779,10 @@ export function Dashboard() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-primary-500 dark:text-primary-400" /> <Settings className="w-5 h-5 text-primary-500 dark:text-primary-400" />
<CardTitle className="text-base">Einstellungen</CardTitle> <CardTitle className="text-base">Settings</CardTitle>
</div> </div>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Schnellzugriff auf wichtige Einstellungen Quick access
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -810,7 +811,7 @@ export function Dashboard() {
aria-label="Open all settings" aria-label="Open all settings"
> >
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Alle Einstellungen All settings
<ExternalLink className="w-3 h-3 ml-auto" /> <ExternalLink className="w-3 h-3 ml-auto" />
</Button> </Button>
</CardContent> </CardContent>
@@ -819,9 +820,9 @@ export function Dashboard() {
{/* Account/System Karte */} {/* Account/System Karte */}
<Card className="border border-slate-200 dark:border-slate-700 shadow-sm"> <Card className="border border-slate-200 dark:border-slate-700 shadow-sm">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-base">Account & System</CardTitle> <CardTitle className="text-base">Account</CardTitle>
<CardDescription className="text-xs"> <CardDescription className="text-xs">
Subscription und Konten-Status Subscription and accounts
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -904,10 +905,10 @@ export function Dashboard() {
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <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 ${ <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 ${ <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> </div>
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{account.email}</p> <p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{account.email}</p>
@@ -932,7 +933,7 @@ export function Dashboard() {
) : ( ) : (
<div className="text-center py-4"> <div className="text-center py-4">
<Mail className="w-8 h-8 text-slate-300 dark:text-slate-600 mx-auto mb-2" /> <Mail className="w-8 h-8 text-slate-300 dark:text-slate-600 mx-auto mb-2" />
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">No accounts connected</p> <p className="text-xs text-slate-500 dark:text-slate-400 mb-2">No inbox connected yet</p>
<Button <Button
onClick={() => navigate('/setup')} onClick={() => navigate('/setup')}
variant="outline" variant="outline"
@@ -941,7 +942,7 @@ export function Dashboard() {
aria-label="Connect email account" aria-label="Connect email account"
> >
<Plus className="w-3 h-3 mr-1.5" /> <Plus className="w-3 h-3 mr-1.5" />
Connect Account Connect inbox
</Button> </Button>
</div> </div>
)} )}

View File

@@ -21,8 +21,8 @@ export function ForgotPassword() {
try { try {
await auth.forgotPassword(email) await auth.forgotPassword(email)
setSent(true) setSent(true)
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Fehler beim Senden der E-Mail') setError(err instanceof Error ? err.message : 'Fehler beim Senden der E-Mail')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -2,8 +2,9 @@ import { Navbar } from '@/components/landing/Navbar'
import { Hero } from '@/components/landing/Hero' import { Hero } from '@/components/landing/Hero'
import { Features } from '@/components/landing/Features' import { Features } from '@/components/landing/Features'
import { HowItWorks } from '@/components/landing/HowItWorks' import { HowItWorks } from '@/components/landing/HowItWorks'
import { Pricing } from '@/components/landing/Pricing'
import { Testimonials } from '@/components/landing/Testimonials' import { Testimonials } from '@/components/landing/Testimonials'
import { TrustSection } from '@/components/landing/TrustSection'
import { Pricing } from '@/components/landing/Pricing'
import { FAQ } from '@/components/landing/FAQ' import { FAQ } from '@/components/landing/FAQ'
import { Footer } from '@/components/landing/Footer' import { Footer } from '@/components/landing/Footer'
@@ -15,6 +16,7 @@ export function Home() {
<Features /> <Features />
<HowItWorks /> <HowItWorks />
<Testimonials /> <Testimonials />
<TrustSection />
<Pricing /> <Pricing />
<FAQ /> <FAQ />
<Footer /> <Footer />

View File

@@ -23,8 +23,8 @@ export function Login() {
try { try {
await login(email, password) await login(email, password)
navigate('/dashboard') navigate('/dashboard')
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Login failed. Please check your credentials.') setError(err instanceof Error ? err.message : 'Login failed. Please check your credentials.')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -65,8 +65,8 @@ export function Register() {
try { try {
await register(email, password, name) await register(email, password, name)
navigate('/setup') navigate('/setup')
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Registration failed. Please try again.') setError(err instanceof Error ? err.message : 'Registration failed. Please try again.')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -51,8 +51,8 @@ export function ResetPassword() {
try { try {
await auth.resetPassword(userId, secret, password) await auth.resetPassword(userId, secret, password)
setSuccess(true) setSuccess(true)
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Fehler beim Zurücksetzen des Passworts') setError(err instanceof Error ? err.message : 'Fehler beim Zurücksetzen des Passworts')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -22,6 +22,7 @@ import { api } from '@/lib/api'
import { import {
Mail, Mail,
User, User,
Users,
CreditCard, CreditCard,
Shield, Shield,
Settings as SettingsIcon, Settings as SettingsIcon,
@@ -54,15 +55,15 @@ import {
Save, Save,
Edit2, Edit2,
} from 'lucide-react' } from 'lucide-react'
import type { AIControlSettings, CompanyLabel, CategoryInfo, CleanupStatus } from '@/types/settings' import type { AIControlSettings, CompanyLabel, NameLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
import { PrivacySecurity } from '@/components/PrivacySecurity' 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 { interface EmailAccount {
id: string id: string
email: string email: string
provider: 'gmail' | 'outlook' provider: 'gmail' | 'outlook' | 'imap'
connected: boolean connected: boolean
lastSync?: string lastSync?: string
} }
@@ -97,6 +98,9 @@ export function Settings() {
const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null) const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([]) const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [connectingProvider, setConnectingProvider] = useState<string | null>(null) 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 [vipSenders, setVipSenders] = useState<VIPSender[]>([])
const [newVipEmail, setNewVipEmail] = useState('') const [newVipEmail, setNewVipEmail] = useState('')
const [subscription, setSubscription] = useState<Subscription | null>(null) const [subscription, setSubscription] = useState<Subscription | null>(null)
@@ -126,6 +130,10 @@ export function Settings() {
}) })
const [categories, setCategories] = useState<CategoryInfo[]>([]) const [categories, setCategories] = useState<CategoryInfo[]>([])
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([]) 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 [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
const [loadingReferral, setLoadingReferral] = useState(false) const [loadingReferral, setLoadingReferral] = useState(false)
@@ -185,42 +193,52 @@ export function Settings() {
setLoading(true) setLoading(true)
try { 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.getEmailAccounts(user.$id),
api.getSubscriptionStatus(user.$id), api.getSubscriptionStatus(user.$id),
api.getUserPreferences(user.$id), api.getUserPreferences(user.$id),
api.getAIControlSettings(user.$id), api.getAIControlSettings(user.$id),
api.getCompanyLabels(user.$id), api.getCompanyLabels(user.$id),
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
]) ])
if (accountsRes.data) setAccounts(accountsRes.data) if (accountsRes.data) setAccounts(accountsRes.data)
if (subsRes.data) setSubscription(subsRes.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 (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
if (aiControlRes.data) { if (aiControlRes.data) {
// Merge cleanup defaults if not present // Merge cleanup defaults if not present
const settings: AIControlSettings = { const raw = aiControlRes.data
...aiControlRes.data, const defaultCleanup: CleanupSettings = {
cleanup: aiControlRes.data.cleanup || { enabled: false,
readItems: {
enabled: false, enabled: false,
readItems: { action: 'archive_read',
enabled: false, gracePeriodDays: 7,
action: 'archive_read' as const,
gracePeriodDays: 7,
},
promotions: {
enabled: false,
matchCategoriesOrLabels: ['promotions', 'newsletters'],
action: 'archive_read' as const,
deleteAfterDays: 30,
},
safety: {
requireConfirmForDelete: true,
dryRun: false,
maxDeletesPerRun: 100,
},
}, },
categoryAdvanced: aiControlRes.data.categoryAdvanced || {}, promotions: {
version: aiControlRes.data.version || 1, enabled: false,
matchCategoriesOrLabels: ['promotions', 'newsletters'],
action: 'archive_read',
deleteAfterDays: 30,
},
safety: {
requireConfirmForDelete: true,
dryRun: false,
maxDeletesPerRun: 100,
},
}
const settings: AIControlSettings = {
...raw,
cleanup: (raw.cleanup as CleanupSettings | undefined) || defaultCleanup,
categoryAdvanced: (raw.categoryAdvanced as Record<string, CategoryAdvanced> | undefined) || {},
version: raw.version ?? 1,
} }
setAiControlSettings(settings) setAiControlSettings(settings)
savedSettingsRef.current = JSON.parse(JSON.stringify(settings)) // Deep copy savedSettingsRef.current = JSON.parse(JSON.stringify(settings)) // Deep copy
@@ -312,7 +330,7 @@ export function Settings() {
try { try {
const res = await api.getCleanupStatus(user.$id) const res = await api.getCleanupStatus(user.$id)
if (res.data) setCleanupStatus(res.data) if (res.data) setCleanupStatus(res.data)
} catch (err) { } catch {
// Silently fail if endpoint doesn't exist yet // Silently fail if endpoint doesn't exist yet
console.debug('Cleanup status endpoint not available') console.debug('Cleanup status endpoint not available')
} }
@@ -324,7 +342,7 @@ export function Settings() {
try { try {
const res = await api.getCleanupPreview(user.$id) const res = await api.getCleanupPreview(user.$id)
if (res.data?.preview) setCleanupPreview(res.data.preview) if (res.data?.preview) setCleanupPreview(res.data.preview)
} catch (err) { } catch {
// Silently fail if endpoint doesn't exist yet // Silently fail if endpoint doesn't exist yet
console.debug('Cleanup preview endpoint not available') console.debug('Cleanup preview endpoint not available')
} }
@@ -476,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 = () => { const handleAddVip = () => {
if (!newVipEmail.trim() || !newVipEmail.includes('@')) return if (!newVipEmail.trim() || !newVipEmail.includes('@')) return
@@ -533,14 +576,18 @@ export function Settings() {
} }
} }
const tabs = [ const tabs = useMemo(() => {
{ id: 'profile' as TabType, label: 'Profile', icon: User }, const base = [
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail }, { id: 'profile' as TabType, label: 'Profile', icon: User },
{ id: 'vip' as TabType, label: 'VIP List', icon: Star }, { id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain }, { id: 'vip' as TabType, label: 'VIP List', icon: Star },
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard }, { id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock }, ...(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 ( return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900"> <div className="min-h-screen bg-slate-50 dark:bg-slate-900">
@@ -851,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 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="flex items-center gap-4">
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${ <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>
<div> <div>
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p> <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> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -926,7 +973,100 @@ export function Settings() {
<p className="text-sm text-slate-500 dark:text-slate-400">Connect Microsoft account</p> <p className="text-sm text-slate-500 dark:text-slate-400">Connect Microsoft account</p>
</div> </div>
</button> </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> </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> </CardContent>
</Card> </Card>
</div> </div>
@@ -1058,7 +1198,7 @@ export function Settings() {
</div> </div>
<select <select
value={filterEnabled} value={filterEnabled}
onChange={(e) => setFilterEnabled(e.target.value as any)} onChange={(e) => setFilterEnabled((e.target.value || 'all') as 'all' | 'enabled' | 'disabled')}
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
> >
<option value="all">All</option> <option value="all">All</option>
@@ -1070,7 +1210,7 @@ export function Settings() {
</Card> </Card>
{/* Tabs */} {/* Tabs */}
<Tabs value={controlPanelTab} onValueChange={(v) => setControlPanelTab(v as any)} className="w-full"> <Tabs value={controlPanelTab} onValueChange={(v) => setControlPanelTab((v || 'rules') as 'rules' | 'cleanup' | 'labels')} className="w-full">
<TabsList className="grid w-full grid-cols-3"> <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="rules" className="text-sm sm:text-base">Rules</TabsTrigger> <TabsTrigger value="rules" className="text-sm sm:text-base">Rules</TabsTrigger>
<TabsTrigger value="cleanup" className="text-sm sm:text-base">Cleanup</TabsTrigger> <TabsTrigger value="cleanup" className="text-sm sm:text-base">Cleanup</TabsTrigger>
@@ -1554,7 +1694,7 @@ export function Settings() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
value={labelSort} value={labelSort}
onChange={(e) => setLabelSort(e.target.value as any)} onChange={(e) => setLabelSort((e.target.value || 'name') as 'name' | 'newest')}
className="px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" className="px-3 py-2 text-sm border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
> >
<option value="name">Sort: Name</option> <option value="name">Sort: Name</option>
@@ -1834,7 +1974,7 @@ export function Settings() {
id="category-action" id="category-action"
value={aiControlSettings.categoryActions[selectedCategory.key] || selectedCategory.defaultAction} value={aiControlSettings.categoryActions[selectedCategory.key] || selectedCategory.defaultAction}
onChange={(e) => { onChange={(e) => {
const newActions = { ...aiControlSettings.categoryActions, [selectedCategory.key]: e.target.value as any } const newActions = { ...aiControlSettings.categoryActions, [selectedCategory.key]: (e.target.value || 'inbox') as 'inbox' | 'archive_read' | 'star' }
setAiControlSettings({ ...aiControlSettings, categoryActions: newActions }) setAiControlSettings({ ...aiControlSettings, categoryActions: newActions })
}} }}
className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent" className="w-full px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
@@ -1881,7 +2021,7 @@ export function Settings() {
onChange={(e) => { onChange={(e) => {
const newAdvanced = { ...aiControlSettings.categoryAdvanced } const newAdvanced = { ...aiControlSettings.categoryAdvanced }
if (!newAdvanced[selectedCategory.key]) newAdvanced[selectedCategory.key] = {} if (!newAdvanced[selectedCategory.key]) newAdvanced[selectedCategory.key] = {}
newAdvanced[selectedCategory.key] = { ...newAdvanced[selectedCategory.key], priority: e.target.value as any } newAdvanced[selectedCategory.key] = { ...newAdvanced[selectedCategory.key], priority: (e.target.value || 'medium') as 'low' | 'medium' | 'high' }
setAiControlSettings({ ...aiControlSettings, categoryAdvanced: newAdvanced }) setAiControlSettings({ ...aiControlSettings, categoryAdvanced: newAdvanced })
}} }}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 mt-1" className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 mt-1"
@@ -2043,6 +2183,226 @@ export function Settings() {
{editingLabel?.id ? 'Save Changes' : 'Create Label'} {editingLabel?.id ? 'Save Changes' : 'Create Label'}
</Button> </Button>
</SidePanelFooter> </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> </SidePanelContent>
</SidePanel> </SidePanel>
</div> </div>

View File

@@ -107,9 +107,7 @@ export function Setup() {
const steps: { id: Step; title: string; description: string }[] = [ const steps: { id: Step; title: string; description: string }[] = [
{ id: 'connect', title: 'Connect email', description: 'Link your mailbox' }, { id: 'connect', title: 'Connect email', description: 'Link your mailbox' },
{ id: 'preferences', title: 'Settings', description: 'Sorting preferences' }, { id: 'complete', title: 'Done', description: 'Go to dashboard' },
{ id: 'categories', title: 'Categories', description: 'Choose categories' },
{ id: 'complete', title: 'Done', description: 'Get started!' },
] ]
const stepIndex = steps.findIndex(s => s.id === currentStep) const stepIndex = steps.findIndex(s => s.id === currentStep)
@@ -128,13 +126,12 @@ export function Setup() {
} else { } else {
setConnectedProvider('gmail') setConnectedProvider('gmail')
setConnectedEmail(user.email) setConnectedEmail(user.email)
setCurrentStep('preferences') setCurrentStep('complete')
// Track onboarding step await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule') trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail') trackProviderConnected(user.$id, 'gmail')
} }
} catch (err) { } catch {
setError('Gmail connection failed. Please try again.') setError('Gmail connection failed. Please try again.')
} finally { } finally {
setConnecting(null) setConnecting(null)
@@ -155,11 +152,10 @@ export function Setup() {
} else { } else {
setConnectedProvider('outlook') setConnectedProvider('outlook')
setConnectedEmail(user.email) setConnectedEmail(user.email)
setCurrentStep('preferences') setCurrentStep('complete')
// Track onboarding step await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
} }
} catch (err) { } catch {
setError('Outlook connection failed. Please try again.') setError('Outlook connection failed. Please try again.')
} finally { } finally {
setConnecting(null) setConnecting(null)
@@ -176,13 +172,12 @@ export function Setup() {
if (response.data) { if (response.data) {
setConnectedProvider('demo') setConnectedProvider('demo')
setConnectedEmail(response.data.email) setConnectedEmail(response.data.email)
setCurrentStep('preferences') setCurrentStep('complete')
// Track onboarding step await api.updateOnboardingStep(user.$id, 'see_results', ['connect'])
await api.updateOnboardingStep(user.$id, 'first_rule', ['connect'])
trackOnboardingStep(user.$id, 'first_rule') trackOnboardingStep(user.$id, 'first_rule')
trackDemoUsed(user.$id) trackDemoUsed(user.$id)
} }
} catch (err) { } catch {
setError('Demo connection failed. Please try again.') setError('Demo connection failed. Please try again.')
} finally { } finally {
setConnecting(null) setConnecting(null)
@@ -240,7 +235,7 @@ export function Setup() {
}) })
// Mark onboarding as completed // Mark onboarding as completed
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'first_rule', 'see_results', 'auto_schedule']) await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results'])
} catch (err) { } catch (err) {
console.error('Failed to save preferences:', err) console.error('Failed to save preferences:', err)
} finally { } finally {
@@ -336,7 +331,7 @@ export function Setup() {
<OnboardingProgress <OnboardingProgress
currentStep={onboardingState.onboarding_step} currentStep={onboardingState.onboarding_step}
completedSteps={onboardingState.completedSteps} completedSteps={onboardingState.completedSteps}
totalSteps={4} totalSteps={2}
onSkip={handleSkipOnboarding} onSkip={handleSkipOnboarding}
/> />
</div> </div>
@@ -469,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" /> <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> </button>
</div> </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>
<div className="mt-10 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl max-w-lg mx-auto"> <div className="mt-10 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl max-w-lg mx-auto">
@@ -588,12 +588,15 @@ export function Setup() {
{currentStep === 'complete' && ( {currentStep === 'complete' && (
<div className="text-center"> <div className="text-center">
<div className="w-28 h-28 mx-auto mb-8 rounded-full bg-gradient-to-br from-green-100 to-green-200 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse"> <div className="w-28 h-28 mx-auto mb-8 rounded-full bg-gradient-to-br from-green-100 to-green-200 dark:from-green-900/40 dark:to-green-800/40 flex items-center justify-center shadow-2xl shadow-green-500/20 animate-pulse">
<Sparkles className="w-14 h-14 text-green-600" /> <Sparkles className="w-14 h-14 text-green-600 dark:text-green-400" />
</div> </div>
<h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">All set! 🎉</h1> <h1 className="text-4xl font-bold text-slate-900 dark:text-slate-100 mb-4">You&apos;re in 🎉</h1>
<p className="text-xl text-slate-600 dark:text-slate-400 mb-10 max-w-md mx-auto"> <p className="text-xl text-slate-600 dark:text-slate-400 mb-6 max-w-md mx-auto">
Your email account is connected. The AI will now start intelligent sorting. Click Sort Now on the dashboard to categorize your inbox. Takes about 30 seconds.
</p>
<p className="text-sm text-slate-500 dark:text-slate-500 mb-10">
<Link to="/settings" className="underline hover:text-slate-700 dark:hover:text-slate-300">Tune categories later in Settings</Link>
</p> </p>
<div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-700 rounded-2xl mb-10 shadow-lg"> <div className="inline-flex items-center gap-4 p-5 bg-gradient-to-r from-slate-50 to-slate-100 dark:from-slate-800 dark:to-slate-700 rounded-2xl mb-10 shadow-lg">

View File

@@ -29,9 +29,9 @@ export function VerifyEmail() {
try { try {
await auth.verifyEmail(userId, secret) await auth.verifyEmail(userId, secret)
setStatus('success') setStatus('success')
} catch (err: any) { } catch (err: unknown) {
setStatus('error') setStatus('error')
setError(err.message || 'Fehler bei der Verifizierung') setError(err instanceof Error ? err.message : 'Fehler bei der Verifizierung')
} }
} }
@@ -43,8 +43,8 @@ export function VerifyEmail() {
await auth.sendVerification() await auth.sendVerification()
setError('') setError('')
alert('Neue Verifizierungs-E-Mail wurde gesendet!') alert('Neue Verifizierungs-E-Mail wurde gesendet!')
} catch (err: any) { } catch (err: unknown) {
setError(err.message || 'Fehler beim Senden') setError(err instanceof Error ? err.message : 'Fehler beim Senden')
} finally { } finally {
setStatus('error') setStatus('error')
} }

View File

@@ -66,6 +66,15 @@ export interface CompanyLabel {
category?: string 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 { export interface CategoryInfo {
key: string key: string
name: string name: string

View File

@@ -0,0 +1,133 @@
# Email Sorter — Product Strategy (2-Week / Reddit Launch)
**Role:** Product owner. **Goal:** First paying users from Reddit (r/buildinpublic, r/SaaS, r/freelance). **Constraint:** Understandable in under 10 seconds.
---
## 1. Homepage & Messaging
### Problems today
- **Hero:** "Clean inbox automatically in minutes" is vague. "Minutes" undersells; "clean" is generic.
- **Subhead:** "Create smart rules, apply in one click" — sounds like manual work, not automatic.
- **Badge:** "AI-powered email sorting" — buzzword; doesnt say who its for or what outcome.
- **CTAs:** "Try Demo" vs "Connect inbox" — two choices slow decision; primary action unclear.
### Proposed copy (exact)
| Element | Current | Proposed |
|--------|---------|----------|
| **Badge** | AI-powered email sorting | For freelancers & small teams |
| **Headline** | Clean inbox automatically in minutes. | **Leads, clients, spam — sorted automatically.** |
| **Subhead** | Create smart rules… | Connect Gmail or Outlook. We put newsletters, promos, and noise in folders so your inbox stays for what pays. |
| **Primary CTA** | Try Demo (first) | **Try it free** (one button; goes to register or demo) |
| **Secondary** | Connect inbox | See how it works (scroll or short demo) |
### Implementation
- One primary CTA above the fold: **Try it free**`/register`. Remove or demote "Try Demo" to a small link under the button: "Or try a 30-second demo first."
- Remove "in minutes" and "smart rules" from hero. No "Inbox Zero" in hero (use only in Features if at all).
- Trust line: keep "No credit card · Gmail & Outlook · GDPR compliant" but shorten to one line.
---
## 2. Activation & Onboarding (60-Second Flow)
### Minimum steps before value
1. **Sign up** (email + password or Google; no long form).
2. **Connect inbox** OR **Try Demo** (pick one as default; Demo gets you to "Sort complete" in one click).
3. **Done** → Dashboard with "Sort Now" or auto-result.
### What to remove or defer
- **Remove:** Step "Settings" (Sorting Intensity: Light/Medium/Strict). Use a single default: Medium. Expose in Settings later.
- **Remove:** Step "Choose your categories". Default: all 6 core categories (VIP, Clients, Invoices, Newsletter, Social, Security). No picker during onboarding.
- **Remove:** "Historical emails" toggle. Default: off for first run (faster). Optional in Settings.
- **Keep:** Connect email (Gmail/Outlook) + Demo. One click to "Done" then Dashboard.
- **Skip button:** Keep "Skip" but rename to "Ill do this later" and only show after theyve seen the connect step (so they can still land on dashboard with empty state).
### 60-second flow (concrete)
1. **015s:** Land on `/register` or home → click "Try it free" → sign up (email or Google).
2. **1545s:** One screen: "Connect Gmail or Outlook" + prominent "Try with sample inbox" (demo). No steps 23.
3. **4560s:** After connect or demo → "Youre in. Click Sort Now." → Dashboard. If demo: one "Sort Now" click → instant result.
### Implementation
- Collapse Setup into **one step**: Connect (with Demo as primary option for first-time). After connect or demo → go straight to Dashboard.
- Move "Sorting intensity" and "Categories" to Settings (and optional "tune later" link from dashboard empty state).
- Default for new users: Demo first (so they see a result in 30s), then "Connect your real inbox to sort it."
---
## 3. Core Feature Focus
### One main selling point
**"Automatic email categories: Leads, clients, invoices, newsletters, spam — without rules."**
- The moment of value: user sees **their** emails (or demo emails) sorted into clear categories and inbox count dropping.
- Everything in the app should point to: connect → sort once → see result. No "AI suggests, you approve" as hero message.
### Features to hide or delay (for 2-week launch)
- **Hide:** "Control Panel", "Smart suggestions" / "Apply suggested rules" as primary path. Keep in dashboard for power users but dont push in onboarding.
- **Hide:** Daily digest / "Todays Digest" for new users (show after 2nd sort or after 7 days).
- **Hide:** Referral / Share results until after first successful sort and upgrade prompt.
- **De-emphasize:** Multiple email accounts (show "1 account" in pricing; multi-account in Settings, not hero).
- **Remove from landing:** "Inbox Zero" as headline (overused). Use "sorted inbox" or "inbox that stays clean."
### Features to keep prominent
- Connect one inbox (Gmail/Outlook).
- **Sort Now** + result: "X emails categorized, inbox reduced by Y, time saved Z."
- Single clear upgrade moment: when they hit limit or after first sort ("Unlimited sorts from $X/month").
---
## 4. UX/UI Improvements
### Trust & clarity
- **Navbar:** Add one line under logo: "B2B email sorting" or keep minimal. CTA: "Try it free" (not "Get started free").
- **Pricing section:** One price for Reddit launch: e.g. **$9/month** or **$7/month** (single plan). "Most Popular" on the only paid plan. Remove Business tier for now.
- **Empty state (Dashboard, no account):** One sentence: "Connect Gmail or Outlook to sort your first emails." One button: "Connect inbox." No extra cards (Control Panel, Einstellungen) until one account is connected.
- **Empty state (Dashboard, account connected, no sort yet):** "Click Sort Now to categorize your inbox. Takes about 30 seconds." Big "Sort Now" button.
- **First-time sort result:** Keep current "First sort complete!" + numbers. Add one line: "Weve put newsletters and promos in folders. Check your inbox — only important mail is left."
### Defaults
- **Onboarding:** Default = Demo (so they see value without OAuth). Then "Connect your real inbox."
- **Categories:** All 6 selected by default; no picker during onboarding.
- **Strictness:** Medium; no selector in flow.
### Skeptical / impatient users
- **Above the fold:** No carousel, no "4 steps". One headline, one subhead, one CTA.
- **FAQ:** Move "Do I need a credit card?" and "Can I cancel anytime?" to top. Add: "What do you do with my email?" → "We only read headers and labels to assign categories. We dont store email content."
- **Footer:** Short. Imprint, Privacy, Contact. No long feature list.
---
## 5. Monetization (Early Stage)
### Pricing that feels "no-brainer" for freelancers
- **Free:** 1 account, 500 emails/month, basic categories. Enough to feel the product.
- **Single paid plan:** **$9/month** (or **$7/month** for first 100 customers). "Unlimited emails, 1 account, all categories, cancel anytime."
- **Remove for now:** $19 Pro, $49 Business. One plan = no choice paralysis.
- **Trial:** 14-day free trial, no card. After trial, card required or account stays free-tier (500/mo).
### Early-adopter experiment
- **Reddit launch offer:** "First 50 from r/SaaS or r/freelance: $5/month for 6 months." Use a coupon or a separate plan ID. Mention in Reddit post and a small banner on pricing: "Reddit launch: $5/mo for 6 months — use code REDDIT50."
- **Churn:** Focus on "Sort Now" success in first 7 days. If theyve done 2+ sorts and connected a real inbox, send one email: "Youve sorted X emails. Upgrade to unlimited for $9/mo." No aggressive upsells.
---
## 6. Retention & Defensibility
### One integration that increases switching cost
- **Gmail labels (or Outlook folders) as the integration.** Product already sorts into categories; make the output visible where they live:
- **Sync categories to Gmail labels** (e.g. "EmailSorter/Clients", "EmailSorter/Newsletter"). User sees labels in Gmail; moving away means losing those labels or redoing work.
- Implementation: After sort, apply Gmail API `users.labels` + `messages.modify` to add the label to each message. One-way: Email Sorter → Gmail. No need for bi-directional sync in v1.
- **Alternative (simpler):** **Weekly digest email.** "You sorted 47 emails this week. Top category: Newsletter (20)." Builds habit and touchpoint; unsubscribing = losing a small benefit.
- **Recommendation:** Gmail (and later Outlook) label sync. Real defensibility; realistic for a solo dev (Gmail API is well documented). Ship "Sync to Gmail labels" as a Pro feature or postfree-trial hook.
---
## Implementation Checklist (Priority Order)
- [ ] **Hero:** New headline, subhead, single CTA "Try it free", demo as secondary link.
- [ ] **Onboarding:** Single step (Connect or Demo) → Dashboard. Move Settings + Categories to Settings page.
- [ ] **Pricing:** One paid plan $9/mo; optional Reddit code REDDIT50 ($5/mo for 6 months).
- [ ] **Dashboard empty states:** Copy and single primary action per state.
- [ ] **FAQ:** Reorder; add "What do you do with my email?"; keep short.
- [ ] **Defensibility:** Design/spec "Sync categories to Gmail labels" for postlaunch.

View File

@@ -146,7 +146,19 @@ tail -f server/logs/webhook.log
- ✅ Prüfe, ob der Server erreichbar ist (`curl https://emailsorter.webklar.com/api/webhook/status`) - ✅ Prüfe, ob der Server erreichbar ist (`curl https://emailsorter.webklar.com/api/webhook/status`)
- ✅ Prüfe Gitea-Logs: **Settings** → **Webhooks** → **Delivery Log** - ✅ Prüfe Gitea-Logs: **Settings** → **Webhooks** → **Delivery Log**
### "Ungültige Webhook-Signatur" (401) ### 502 Bad Gateway (von nginx)
Nginx meldet 502, wenn das Backend (Node/PM2) nicht antwortet oder abstürzt.
- ✅ **Backend läuft:** `pm2 list` Prozess muss „online“ sein
- ✅ **Backend neu starten:** `pm2 restart all` oder `pm2 start ecosystem.config.js`
- ✅ **Logs prüfen:** `pm2 logs` beim nächsten „Test Push“ sofort Fehler ansehen
- ✅ **Health prüfen:** `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health` → sollte `200` sein
- ✅ **Nginx-Upstream:** `proxy_pass` muss auf den richtigen Port zeigen (z.B. `http://127.0.0.1:3000`)
Nach einem Code-Deploy (größeres Body-Limit, robustere Fehlerbehandlung) Backend neu starten: `pm2 restart all`.
### "Ungültige Webhook-Signatur" (401/403)
- ✅ Prüfe, ob `GITEA_WEBHOOK_SECRET` in `server/.env` gesetzt ist - ✅ Prüfe, ob `GITEA_WEBHOOK_SECRET` in `server/.env` gesetzt ist
- ✅ Prüfe, ob das Secret in Gitea **genau gleich** ist (keine Leerzeichen!) - ✅ Prüfe, ob das Secret in Gitea **genau gleich** ist (keine Leerzeichen!)

View File

@@ -0,0 +1,41 @@
# Anleitung für SSH nur EmailSorter (emailsorter.webklar.com) fixen
**Kopiere den folgenden Abschnitt und schick ihn an die Person am Server (oder nutze ihn als eigene Checkliste):**
---
## Kontext
- **Nur diese Website:** **emailsorter.webklar.com** (EmailSorter / Gitea-Webhook).
- **Nicht anfassen:** Alle anderen Websites/Projekte auf dem gleichen Server.
- **Problem:** Beim Gitea-Webhook („Test Push Event“) kommt **502 Bad Gateway** von nginx. Das Backend (Node/PM2) für emailsorter.webklar.com soll geprüft und ggf. neu gestartet werden.
## Was ich brauche
1. **PM2 prüfen (nur für EmailSorter):**
- `pm2 list` ausführen.
- Den Prozess finden, der zu **emailsorter.webklar.com** / EmailSorter gehört (Name oder Script-Pfad wie `server/index.mjs` oder `emailsorter`).
- Prüfen: Läuft er (Status „online“)? Wenn „stopped“ oder „errored“: das ist wahrscheinlich die Ursache für den 502.
2. **Backend für EmailSorter neu starten:**
- Nur den PM2-Prozess für EmailSorter neu starten (nicht `pm2 restart all`, wenn andere Sites davon betroffen wären).
- Beispiel, wenn der Prozess „emailsorter“ heißt: `pm2 restart emailsorter`
- Oder nur den einen Eintrag in der Liste per Name/ID neu starten.
3. **Env für EmailSorter prüfen (optional, nur wenn Webhook weiter 502/401 gibt):**
- In das Projektverzeichnis von EmailSorter wechseln (z.B. `/var/www/emailsorter` oder wo auch immer es liegt).
- Prüfen, ob in `server/.env` (oder im Root-`.env`) steht:
`GITEA_WEBHOOK_SECRET=<dein Webhook-Secret>`
- Wenn nicht: diese Zeile in der richtigen `.env` ergänzen (Secret bekommst du separat / steht in Gitea unter Webhook → Secret). Danach nur den EmailSorter-PM2-Prozess neu starten.
4. **Kurz testen:**
- `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/health`
Sollte `200` ausgeben.
- `curl -s -o /dev/null -w "%{http_code}" https://emailsorter.webklar.com/api/webhook/status`
Sollte ebenfalls `200` ausgeben.
5. **Nichts anderes ändern:** Keine anderen Projekte, keine globalen nginx-/System-Konfigurationen anpassen nur EmailSorter (emailsorter.webklar.com) wie oben beschrieben.
---
**Wenn du den Code gerade neu deployed hast (git pull für EmailSorter):** Danach bitte nur den PM2-Prozess für EmailSorter neu starten (z.B. `pm2 restart <name-oder-id>`), damit die neuen Webhook-Fixes aktiv sind.

View File

@@ -27,8 +27,9 @@ USE_PM2=true
1. Gehe zu deinem Repository → **Settings****Webhooks** 1. Gehe zu deinem Repository → **Settings****Webhooks**
2. Klicke **Add Webhook****Gitea** 2. Klicke **Add Webhook****Gitea**
3. Fülle aus: 3. Fülle aus:
- **Target URL:** `https://emailsorter.webklar.com/api/webhook/gitea` - **Target URL:** `https://emailsorter.webklar.com/api/webhook/gitea` (Produktion)
- **Secret:** `dein_generiertes_secret_hier` (aus Schritt 1) - **Secret:** `dein_generiertes_secret_hier` (aus Schritt 1)
- **Authorization Header (optional):** `Bearer dein_generiertes_secret_hier` (gleicher Wert wie Secret)
- **Trigger On:** ✅ **Push Events** - **Trigger On:** ✅ **Push Events**
- **Branch Filter:** `main` oder `master` - **Branch Filter:** `main` oder `master`
4. Klicke **Add Webhook** 4. Klicke **Add Webhook**

View File

@@ -0,0 +1,183 @@
# Implementierungsplan: IMAP / Porkbun / Nextcloud
Plan, um EmailSorter um einen **IMAP-Provider** (z.B. Porkbun) zu erweitern. Dann funktioniert die Sortierung auch für Postfächer, die in Nextcloud Mail genutzt werden.
---
## Übersicht
| Phase | Inhalt | Aufwand (grobe Schätzung) |
|-------|--------|----------------------------|
| **1** | IMAP-Bibliothek + Service-Grundgerüst | 12 h |
| **2** | Datenbank + Connect-Route für IMAP | 1 h |
| **3** | Sortier-Logik für IMAP (Ordner statt Labels) | 23 h |
| **4** | Frontend: IMAP-Verbindung anlegen | 12 h |
| **5** | Testen, Feinschliff, Doku | 1 h |
---
## Phase 1: IMAP-Bibliothek und Service
**Ziel:** Backend kann sich per IMAP (z.B. Porkbun) verbinden, INBOX auflisten und E-Mails lesen.
### 1.1 Abhängigkeit hinzufügen
- **Datei:** `server/package.json`
- **Aktion:** Dependency `imapflow` hinzufügen (moderner IMAP-Client für Node, SSL-Support).
- **Befehl:** `npm install imapflow` im Ordner `server/`.
### 1.2 Neuer Service
- **Datei (neu):** `server/services/imap.mjs`
- **Inhalt (Kern-Interface):**
- **Konstruktor:** `ImapService({ host, port, secure, user, password })` z.B. für Porkbun: `host: 'imap.porkbun.com', port: 993, secure: true`.
- **connect()** Verbindung aufbauen (login).
- **listEmails(maxResults, fromSeq?)** Nachrichten aus INBOX (z.B. per FETCH ENVELOPE), Rückgabe: `{ messages: [{ id, uid, ... }], nextSeq }`.
- **getEmail(messageId)** bzw. **batchGetEmails(ids)** eine bzw. mehrere Mails laden, Rückgabe-Format wie Gmail/Outlook: `{ id, headers: { from, subject }, snippet }`.
- **close()** Verbindung sauber trennen (LOGOUT).
- **Hinweis:** IMAP nutzt oft UID oder Sequence Number als „id“; einheitlich als `id` nach außen geben (String), damit die Sortier-Route wie bei Gmail/Outlook arbeitet.
### 1.3 Akzeptanz Phase 1
- Ein kleines Test-Script (z.B. `server/scripts/test-imap.mjs`) oder ein temporärer Route-Handler liest Umgebungsvariablen (IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD), baut `ImapService` auf, ruft `listEmails(10)` und `getEmail(...)` auf und loggt das Ergebnis. Keine Credentials im Repo nur `.env` / Umgebungsvariablen.
---
## Phase 2: Datenbank und Connect-Route
**Ziel:** Ein neuer Account-Typ „imap“ kann angelegt werden; Zugangsdaten werden gespeichert.
### 2.1 Datenbank (Appwrite)
- **Datei:** `server/bootstrap-v2.mjs` (oder separates Migrations-Script).
- **Aktion:** In der Collection `email_accounts` optionale Attribute anlegen:
- `imapHost` (String, optional)
- `imapPort` (Integer, optional)
- `imapSecure` (Boolean, optional)
- **Alternative (einfacher für nur Porkbun):** Keine neuen Felder; Host/Port im Code fest (imap.porkbun.com, 993). Dann nur `email` + Passwort nötig; Passwort in bestehendem Feld `accessToken` speichern (semantisch „geheimer Token für IMAP“). Für spätere andere IMAP-Server die optionalen Felder nachziehen.
### 2.2 Connect-Route erweitern
- **Datei:** `server/routes/email.mjs`
- **Route:** `POST /api/email/connect` (bzw. die Route, die Accounts anlegt).
- **Aktionen:**
- Im Validierungs-Schema `provider` um `'imap'` erweitern: z.B. `rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])`.
- Body für IMAP: mindestens `userId`, `provider: 'imap'`, `email`, `password` (oder `accessToken` als Passwort). Optional: `imapHost`, `imapPort`, `imapSecure`.
- Wenn `provider === 'imap'`:
- Host/Port/Secure aus Body oder Default (Porkbun: imap.porkbun.com, 993, true).
- Passwort nicht loggen; in DB in `accessToken` (oder neuem Feld) speichern.
- Optional: einmalig `ImapService` instanziieren, `connect()` + `listEmails(1)` aufrufen; bei Erfolg Account anlegen, sonst Fehler zurückgeben („Ungültige Anmeldedaten“).
- Account-Dokument anlegen mit `provider: 'imap'`, `email`, `accessToken` (= Passwort), ggf. `imapHost`, `imapPort`, `imapSecure`.
### 2.3 Middleware/Validierung
- **Datei:** `server/middleware/validate.mjs` (falls dort Regeln liegen) oder direkt in der Route.
- **Aktion:** Für IMAP ggf. zusätzliche Felder erlauben: `imapHost`, `imapPort`, `imapSecure`, `password` (oder wie du das Feld nennst).
### 2.4 Akzeptanz Phase 2
- Per API-Client (Postman/curl) oder Frontend: POST mit `provider: 'imap'`, `email`, `password` (und optional Host/Port) an `/connect` senden. Erwartung: 201, Account in Appwrite mit `provider: 'imap'`. Bei falschem Passwort: 4xx mit verständlicher Meldung.
---
## Phase 3: Sortier-Logik für IMAP
**Ziel:** `POST /api/email/sort` funktioniert für Accounts mit `provider === 'imap'`: E-Mails werden per KI kategorisiert und in IMAP-Ordner verschoben.
### 3.1 Ordner-Mapping
- **Konzept:** Kategorien (z.B. `vip`, `promotions`, `newsletters`, `archive`) auf Ordner-Namen mappen. Z.B.:
- `archive` / `archive_read` → Ordner `Archive` oder `EmailSorter/Archive`
- `promotions``Promotions` oder `EmailSorter/Promotions`
- usw.
- **Datei:** Entweder in `server/services/imap.mjs` (Funktion `getFolderNameForCategory(category)`) oder in `server/services/ai-sorter.mjs` / Config. Einheitliche Liste (z.B. Objekt `categoryToFolder`) verwenden.
### 3.2 IMAP-Service erweitern
- **Datei:** `server/services/imap.mjs`
- **Neue Methoden:**
- **ensureFolder(folderName)** Ordner anlegen (CREATE), falls nicht vorhanden; Fehler „existiert bereits“ ignorieren.
- **moveToFolder(messageId, folderName)** Nachricht aus INBOX in den Ordner verschieben (MOVE oder COPY + DELETE aus INBOX).
- Optional: **markAsRead(messageId)** falls „archive_read“ = verschieben + als gelesen markieren.
### 3.3 Sortier-Route erweitern
- **Datei:** `server/routes/email.mjs`
- **Stelle:** Dort, wo `account.provider === 'gmail'` und `=== 'outlook'` abgefragt werden (und Demo).
- **Aktion:** Neuen Block `else if (account.provider === 'imap')` hinzufügen:
1. `ImapService` aus Account-Daten instanziieren (host, port, secure, user = email, password = accessToken).
2. `connect()`.
3. In einer Schleife (analog Gmail/Outlook):
- `listEmails(batchSize, nextSeq)` → Liste von Nachrichten.
- `batchGetEmails(ids)` → From, Subject, Snippet.
- Für jede E-Mail: KI-Kategorie ermitteln (bestehender `AISorterService`), dann `ensureFolder(categoryToFolder[category])` und `moveToFolder(id, folderName)`.
- Bei „archive_read“ ggf. zusätzlich als gelesen markieren.
4. Statistiken aktualisieren (wie bei Gmail/Outlook).
5. `close()` aufrufen.
- **Fehlerbehandlung:** Bei IMAP-Fehlern (z.B. „Invalid credentials“) sinnvolle Meldung zurückgeben und ggf. Account als „reconnect nötig“ markieren.
### 3.4 Akzeptanz Phase 3
- Ein IMAP-Account ist verbunden. Aufruf von `POST /api/email/sort` mit `userId` und `accountId`. Erwartung: E-Mails aus INBOX werden kategorisiert und in die richtigen Ordner verschoben; Response enthält z.B. `sortedCount` und Kategorie-Statistiken. In Nextcloud Mail (oder anderem IMAP-Client) erscheinen die neuen Ordner und verschobenen Mails.
---
## Phase 4: Frontend IMAP verbinden
**Ziel:** Nutzer können im UI „Anderes Postfach (IMAP)“ wählen und E-Mail + Passwort eingeben.
### 4.1 Verbindungs-Flow
- **Datei(en):** Dort, wo heute Gmail/Outlook/Demo angeboten werden (z.B. Setup, Settings, „E-Mail verbinden“).
- **Aktion:**
- Neue Option „IMAP / anderes Postfach“ (oder „Porkbun / eigenes Postfach“).
- Beim Klick: Formular anzeigen mit:
- E-Mail (Pflicht)
- Passwort / App-Passwort (Pflicht, Typ Passwort)
- Optional (z.B. für Power-User): Host, Port, SSL (Checkbox); Defaults: imap.porkbun.com, 993, SSL an.
- Submit: POST an Backend (z.B. `/api/email/connect`) mit `provider: 'imap'`, `email`, `password`, optional `imapHost`, `imapPort`, `imapSecure`.
- Bei Erfolg: Erfolgsmeldung, Account-Liste aktualisieren. Bei Fehler: Meldung anzeigen (z.B. „Anmeldung fehlgeschlagen prüfe E-Mail und Passwort“).
### 4.2 API-Client (Frontend)
- **Datei:** z.B. `client/src/lib/api.ts`
- **Aktion:** Methode `connectImapAccount(userId, { email, password, imapHost?, imapPort?, imapSecure? })` hinzufügen, die `POST /api/email/connect` mit diesen Daten aufruft.
### 4.3 Akzeptanz Phase 4
- Im UI „IMAP verbinden“ auswählen, E-Mail + Passwort eingeben, absenden. Account erscheint in der Account-Liste. Danach „Sortieren“ auslösbar und funktioniert wie in Phase 3.
---
## Phase 5: Testen und Doku
- **Manuell:** Mit einem echten Porkbun-Account (oder anderem IMAP) verbinden, Sortierung ausführen, in Nextcloud prüfen, ob Ordner und Mails stimmen.
- **Sicherheit:** Prüfen, dass Passwörter nirgends geloggt werden und nicht im Frontend gespeichert werden.
- **Doku:** `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` ggf. um „Konfiguration Porkbun“ und „Troubleshooting“ ergänzen (z.B. App-Passwort, 2FA).
---
## Kurz-Checkliste
- [x] Phase 1: `imapflow` installiert, `server/services/imap.mjs` mit connect, listEmails, getEmail, close; Test mit .env-Credentials.
- [x] Phase 2: Appwrite `email_accounts` ggf. um IMAP-Felder erweitert; Connect-Route akzeptiert `imap` und speichert Zugangsdaten; Test: Account per API anlegen.
- [x] Phase 3: Ordner-Mapping; ImapService: ensureFolder, moveToFolder; Sortier-Route: Block für `provider === 'imap'`; Test: Sortierung für IMAP-Account.
- [x] Phase 4: Frontend-Option „IMAP“, Formular E-Mail/Passwort, API-Anbindung; Test: End-to-End Verbindung + Sortierung aus UI.
- [ ] Phase 5: Manueller Test mit Porkbun/Nextcloud; Sicherheits-Check; Doku aktualisiert.
---
## Dateien-Übersicht
| Aktion | Datei |
|--------|--------|
| Neu | `server/services/imap.mjs` |
| Neu (optional) | `server/scripts/test-imap.mjs` |
| Ändern | `server/package.json` (imapflow) |
| Ändern | `server/bootstrap-v2.mjs` (optional: IMAP-Attribute) |
| Ändern | `server/routes/email.mjs` (provider imap, connect + sort) |
| Ändern | `server/middleware/validate.mjs` (falls nötig) |
| Ändern | Frontend: Connect-UI (Setup/Settings) + `client/src/lib/api.ts` |
| Ändern | `docs/setup/IMAP_NEXTCLOUD_PORKBUN.md` (Feinschliff) |
Wenn du mit Phase 1 startest, reicht zunächst: `imapflow` einbinden und `imap.mjs` mit connect + listEmails + getEmail implementieren und lokal mit Porkbun testen.

View File

@@ -0,0 +1,189 @@
# IMAP / Nextcloud / Porkbun Integration
## Ziel
EmailSorter soll E-Mails nutzen, die über **Porkbun** (SMTP/IMAP) laufen und ggf. in **Nextcloud Mail** genutzt werden.
**Porkbun (von dir genutzt):**
| Dienst | Host | Port | Verschlüsselung |
|--------|------|------|-----------------|
| IMAP | imap.porkbun.com | 993 | SSL (SSL/TLS) |
| SMTP | smtp.porkbun.com | 587 | STARTTLS |
| SMTP (Alt.) | smtp.porkbun.com | 50587 | STARTTLS |
| SMTP | smtp.porkbun.com | 465 | Implicit TLS |
| POP | pop.porkbun.com | 995 | SSL (SSL/TLS) |
Für **Sortieren/Lesen** reicht **IMAP** (993, SSL). SMTP wird nur zum Senden gebraucht; EmailSorter sortiert nur, also: IMAP-Anbindung ist der relevante Teil.
---
## Aktueller Stand in EmailSorter
- **Unterstützt:** **Gmail** (OAuth), **Outlook** (OAuth), **IMAP** (E-Mail + Passwort/App-Passwort), **Demo** (Fake-Daten).
- **IMAP:** Generischer IMAP-Provider ist implementiert; Standard ist Porkbun (`imap.porkbun.com`, 993, SSL), andere IMAP-Server über „Advanced“ (Host/Port/SSL) konfigurierbar.
Ablauf:
- **Gmail:** `GmailService(accessToken, refreshToken)` → Gmail API (messages.list, get, labels).
- **Outlook:** `OutlookService(accessToken)` → Microsoft Graph (Mail API).
- **IMAP:** `ImapService(host, port, secure, user, password)` → IMAP (INBOX lesen, Ordner anlegen, Mails verschieben).
- **Demo:** feste Test-E-Mails, kein echter Zugriff.
Accounts werden in `email_accounts` mit `provider`, `email`, `accessToken` (bei IMAP = Passwort), optional `imapHost`, `imapPort`, `imapSecure` gespeichert.
---
## Was „Nextcloud integrieren“ bedeuten kann
1. **Nextcloud nur als Mail-Client**
- Nextcloud Mail nutzt im Hintergrund IMAP/SMTP (z.B. Porkbun).
- EmailSorter spricht **direkt mit dem gleichen IMAP-Server** (Porkbun), nicht mit Nextcloud.
- Nutzer verbindet in EmailSorter sein **Porkbun-Postfach** (IMAP: imap.porkbun.com, 993, E-Mail + App-Passwort).
- Dann: E-Mails, die in Nextcloud sichtbar sind, sind auch für EmailSorter über IMAP erreichbar und umgekehrt (Sortierung über EmailSorter wirkt in Nextcloud, weil dasselbe Postfach).
2. **Nextcloud als Identity/SSO**
- Würde bedeuten: Login bei EmailSorter über Nextcloud (OIDC/SAML). Das ist ein separates Thema (Auth), nicht die E-Mail-Sortierung.
3. **Nextcloud Mail API**
- Theoretisch könnte man die Nextcloud Mail-API ansprechen; typischerweise nutzt man aber direkt IMAP, weil es einfacher und überall gleich ist.
**Pragmatisch:** „In Nextcloud integrieren“ heißt hier: **IMAP-Provider in EmailSorter** so einbauen, dass du **Porkbun (IMAP)** verbinden kannst. Alles, was in Nextcloud über dieses Postfach läuft, wird damit automatisch mit EmailSorter synchron sein.
---
## Technisch: Was für IMAP (Porkbun) nötig ist
### 1. Neuer Provider `imap`
- In **Backend** (`server/routes/email.mjs`): `provider` um `'imap'` erweitern (z.B. neben `gmail`, `outlook`, `demo`).
- Beim **Verbinden** eines Accounts: für IMAP keine OAuth-Tokens, sondern z.B.:
- `imapHost` (z.B. `imap.porkbun.com`)
- `imapPort` (993)
- `imapSecure` (true für SSL)
- `email` (Login = E-Mail-Adresse)
- Passwort/App-Passwort (sicher speichern, z.B. in einem bestehenden Token-Feld oder neuem verschlüsselten Feld)
### 2. Datenbank (Appwrite) `email_accounts`
- Optional neue Attribute, z.B.:
- `imapHost` (string)
- `imapPort` (integer)
- `imapSecure` (boolean)
- Oder: für **nur Porkbun** Host/Port fest im Code (imap.porkbun.com, 993) und nur E-Mail + Passwort in DB speichern (z.B. in `accessToken` als Passwort, oder eigenes Feld).
### 3. Neuer Service `server/services/imap.mjs`
- **IMAP-Client** in Node (z.B. `imapflow` gut für Node, SSL, modern).
- Interface analog zu Gmail/Outlook:
- **listEmails(maxResults, pageToken)** → Liste von Nachrichten aus INBOX (UIDs/Seq + ggf. Envelope).
- **getEmail(messageId)** / **batchGetEmails(ids)** → From, Subject, Snippet (Body-Preview).
- **applySorting(messageId, category)** → bei IMAP: **Ordner** statt Labels (z.B. „Archive“, „Promotions“). D.h.:
- Ordner anlegen, falls nicht vorhanden (CREATE wenn nötig).
- Nachricht in den passenden Ordner **verschieben** (MOVE oder COPY + DELETE aus INBOX).
- Gmail nutzt Labels; IMAP nutzt **Folders**. Die Logik „Kategorie X“ muss also auf „Folder X“ gemappt werden (z.B. `Archive`, `Promotions`, `Newsletter`).
### 4. Sortier-Route `POST /api/email/sort`
- Wenn `account.provider === 'imap'`:
- `ImapService` mit gespeicherten IMAP-Daten instanziieren.
- Wie bei Gmail/Outlook: E-Mails holen → KI kategorisieren → Aktionen anwenden. Bei IMAP: Aktion = „in Ordner X verschieben“ statt „Label setzen“.
### 5. Frontend (Client)
- Neue Option „E-Mail mit IMAP verbinden“ (z.B. „Anderes Postfach (IMAP)“).
- Formular: E-Mail, App-Passwort; optional Host/Port (oder vorkonfiguriert für Porkbun).
- Kein OAuth-Flow; nach Submit werden Zugangsdaten an das Backend geschickt, Backend speichert sie und testet die Verbindung (z.B. einmaliger LOGIN + SELECT INBOX + DISCONNECT).
### 6. Sicherheit
- Passwort/App-Passwort **niemals** im Frontend speichern; nur beim Verbinden einmal an Backend senden.
- Im Backend: verschlüsselt oder in sicherem Secret-Storage ablegen (z.B. nur in DB, Zugriff nur server-seitig).
---
## Konfiguration in EmailSorter
1. **Einstellungen → Accounts** (oder Setup-Seite: Link „Add your account in Settings → Accounts“).
2. Auf **„IMAP / Other“** klicken es öffnet sich ein Formular.
3. **E-Mail** und **Passwort** (bzw. App-Passwort bei 2FA) eintragen.
4. Optional **„Advanced (host, port, SSL)“** aufklappen:
- **IMAP host:** Standard `imap.porkbun.com` (für andere Anbieter z.B. `imap.gmail.com` oder Nextcloud-IMAP-Host).
- **Port:** Standard **993** (SSL).
- **Use SSL:** aktiviert lassen für 993.
5. **„Connect IMAP“** klicken. Das Backend testet die Verbindung; bei Erfolg erscheint das Konto in der Account-Liste. Danach kann **„Sortieren“** wie bei Gmail/Outlook genutzt werden (E-Mails werden in IMAP-Ordner verschoben).
---
## So richtest du es in Nextcloud ein
EmailSorter wird **nicht in Nextcloud installiert**. Beide nutzen **dasselbe Postfach per IMAP**: Nextcloud Mail als Client zum Lesen/Schreiben, EmailSorter zum automatischen Sortieren. Ordner und verschobene Mails sind in beiden sichtbar.
### 1. In Nextcloud Mail: Postfach hinzufügen (falls noch nicht vorhanden)
1. In Nextcloud einloggen → **Mail**-App öffnen.
2. **Konto hinzufügen** (oder **Einstellungen** des Mail-Kontos).
3. **E-Mail-Adresse** und **Passwort** (bzw. **App-Passwort** bei 2FA) eintragen.
4. **IMAP-Server** manuell einstellen (nicht „Auto“), damit dieselben Werte wie in EmailSorter genutzt werden:
- **IMAP:**
- Server: `imap.porkbun.com` (bzw. dein IMAP-Host)
- Port: **993**
- Verschlüsselung: **SSL/TLS**
- **SMTP** (zum Senden):
- Server: `smtp.porkbun.com`
- Port: **587** (STARTTLS) oder **465** (SSL)
- Nutzer/Passwort wie IMAP
5. Speichern. Das Postfach erscheint in Nextcloud Mail; du liest und schreibst wie gewohnt.
### 2. In EmailSorter: dasselbe Postfach verbinden
1. Bei **EmailSorter** einloggen (z.B. emailsorter.webklar.com).
2. **Einstellungen → Accounts****„IMAP / Other“** klicken.
3. **Gleiche E-Mail-Adresse** und **gleiches Passwort** (bzw. App-Passwort) wie in Nextcloud eintragen.
4. Bei Porkbun reicht der Standard (**Advanced** geschlossen). Anderer Anbieter: **Advanced** öffnen und **IMAP-Host** (z.B. `imap.porkbun.com`), **Port 993**, **Use SSL** an setzen.
5. **„Connect IMAP“** klicken. Wenn die Verbindung klappt, erscheint das Konto unter „Connected Email Accounts“.
### 3. Nutzung
- **Nextcloud Mail:** E-Mails lesen, schreiben, Ordner manuell nutzen wie bisher.
- **EmailSorter:** Im Dashboard **„Sortieren“** ausführen. EmailSorter liest die INBOX, kategorisiert per KI und **verschiebt** Mails in Ordner (z.B. Archive, Promotions, Newsletter).
- **In Nextcloud:** Diese Ordner und die verschobenen Mails erscheinen automatisch, weil dasselbe IMAP-Postfach genutzt wird. Gegebenenfalls Mail-App aktualisieren oder kurz warten, bis die Ordnerliste neu geladen ist.
Es ist **keine Installation oder App in Nextcloud** nötig nur dasselbe Konto in Nextcloud Mail (IMAP) und in EmailSorter (IMAP) einrichten.
---
## Porkbun-spezifisch (kurz)
- **IMAP:** `imap.porkbun.com`, Port **993**, SSL.
- **Login:** volle E-Mail-Adresse + Passwort oder **App-Passwort** (wenn 2FA aktiv).
- In EmailSorter: Provider **IMAP** mit Standard Host/Port für Porkbun; andere IMAP-Server über „Advanced“ einstellbar.
---
## Troubleshooting
- **„Login failed check email and password“**
- E-Mail-Adresse exakt wie beim Anbieter (Groß-/Kleinschreibung bei manchen Servern relevant).
- Bei **2FA (Porkbun/Provider):** normales Passwort reicht oft nicht **App-Passwort** in den Account-Einstellungen des Anbieters erzeugen und dieses im EmailSorter-Formular eintragen.
- **Verbindung baut nicht auf (Timeout / SSL-Fehler)**
- Port **993** und **Use SSL** aktiviert für TLS.
- Firewall/Netzwerk: ausgehende Verbindung zu `imap.porkbun.com:993` erlauben.
- Bei eigenem IMAP-Server: Host/Port in „Advanced“ prüfen (z.B. 143 nur mit STARTTLS, nicht „Use SSL“ im gleichen Sinne bei Zweifel 993 + SSL verwenden).
- **Sortierung läuft, Ordner erscheinen in Nextcloud nicht**
- Nextcloud Mail nutzt dasselbe IMAP-Postfach; Ordner sollten nach kurzer Zeit sichtbar sein. Mail-App ggf. aktualisieren oder Abo des Postfachs prüfen.
---
## Reihenfolge der Umsetzung (Vorschlag)
1. **IMAP-Bibliothek** im Backend (z.B. `imapflow`) einbinden.
2. **`server/services/imap.mjs`** implementieren: connect, listEmails, getEmail, moveToFolder, createFolder.
3. **DB/Bootstrap:** `email_accounts` um IMAP-Felder erweitern (oder Nutzung bestehender Felder definieren).
4. **Route `/connect`:** für `provider: 'imap'` Host/Port/User/Passwort entgegennehmen und Account anlegen.
5. **Route `/sort`:** für `provider === 'imap'` die gleiche Sortier-Pipeline wie bei Gmail/Outlook, aber mit `ImapService` und Ordner-Verschiebung statt Labels.
6. **Frontend:** Verbindungs-UI für IMAP (E-Mail + Passwort, ggf. Host/Port).
Wenn du willst, kann als Nächstes ein konkreter Implementierungsplan (mit Dateinamen und API-Skizzen) oder ein kleines Proof-of-Concept nur für „Connect + Liste INBOX“ für Porkbun-IMAP ausgearbeitet werden.

View File

@@ -161,6 +161,12 @@ async function setupCollections() {
db.createBooleanAttribute(DB_ID, 'email_accounts', 'isActive', true)); db.createBooleanAttribute(DB_ID, 'email_accounts', 'isActive', true));
await ensureAttribute('email_accounts', 'lastSync', () => await ensureAttribute('email_accounts', 'lastSync', () =>
db.createDatetimeAttribute(DB_ID, 'email_accounts', 'lastSync', false)); db.createDatetimeAttribute(DB_ID, 'email_accounts', 'lastSync', false));
await ensureAttribute('email_accounts', 'imapHost', () =>
db.createStringAttribute(DB_ID, 'email_accounts', 'imapHost', 256, false));
await ensureAttribute('email_accounts', 'imapPort', () =>
db.createIntegerAttribute(DB_ID, 'email_accounts', 'imapPort', false));
await ensureAttribute('email_accounts', 'imapSecure', () =>
db.createBooleanAttribute(DB_ID, 'email_accounts', 'imapSecure', false));
// ==================== Email Stats ==================== // ==================== Email Stats ====================
await ensureCollection('email_stats', 'Email Stats', PERM_AUTHENTICATED); await ensureCollection('email_stats', 'Email Stats', PERM_AUTHENTICATED);

View File

@@ -75,6 +75,18 @@ export const config = {
emailAccounts: 1, emailAccounts: 1,
autoSchedule: false, // manual only autoSchedule: false, // manual only
}, },
// Admin: comma-separated list of emails with admin rights (e.g. support)
adminEmails: (process.env.ADMIN_EMAILS || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean),
// Gitea Webhook (Deployment)
gitea: {
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '',
},
} }
/** /**
@@ -141,4 +153,12 @@ export const features = {
ai: () => Boolean(config.mistral.apiKey), ai: () => Boolean(config.mistral.apiKey),
} }
/**
* Check if an email has admin rights (support, etc.)
*/
export function isAdmin(email) {
if (!email || typeof email !== 'string') return false
return config.adminEmails.includes(email.trim().toLowerCase())
}
export default config export default config

View File

@@ -65,6 +65,23 @@ MICROSOFT_CLIENT_ID=xxx-xxx-xxx
MICROSOFT_CLIENT_SECRET=xxx MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
# ─────────────────────────────────────────────────────────────────────────────
# Admin (OPTIONAL)
# ─────────────────────────────────────────────────────────────────────────────
# Comma-separated list of admin emails (e.g. support@webklar.com). Used by isAdmin().
# ADMIN_EMAILS=support@webklar.com
# Initial password for admin user when running: npm run create-admin
# ADMIN_INITIAL_PASSWORD=your-secure-password
# ─────────────────────────────────────────────────────────────────────────────
# Gitea Webhook (OPTIONAL Deployment bei Push)
# ─────────────────────────────────────────────────────────────────────────────
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
# GITEA_WEBHOOK_SECRET=dein_webhook_secret
# Optional: anderer Token nur für Authorization: Bearer (sonst wird GITEA_WEBHOOK_SECRET verwendet)
# GITEA_WEBHOOK_AUTH_TOKEN=
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Rate Limiting (OPTIONAL) # Rate Limiting (OPTIONAL)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View File

@@ -11,7 +11,7 @@ import { dirname, join } from 'path'
// Config & Middleware // Config & Middleware
import { config, validateConfig } from './config/index.mjs' import { config, validateConfig } from './config/index.mjs'
import { errorHandler, asyncHandler, NotFoundError, ValidationError } from './middleware/errorHandler.mjs' import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
import { respond } from './utils/response.mjs' import { respond } from './utils/response.mjs'
import { logger, log } from './middleware/logger.mjs' import { logger, log } from './middleware/logger.mjs'
import { limiters } from './middleware/rateLimit.mjs' import { limiters } from './middleware/rateLimit.mjs'
@@ -22,6 +22,7 @@ import emailRoutes from './routes/email.mjs'
import stripeRoutes from './routes/stripe.mjs' import stripeRoutes from './routes/stripe.mjs'
import apiRoutes from './routes/api.mjs' import apiRoutes from './routes/api.mjs'
import analyticsRoutes from './routes/analytics.mjs' import analyticsRoutes from './routes/analytics.mjs'
import webhookRoutes from './routes/webhook.mjs'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
@@ -56,6 +57,11 @@ app.use('/api', limiters.api)
// Static files // Static files
app.use(express.static(join(__dirname, '..', 'public'))) app.use(express.static(join(__dirname, '..', 'public')))
// Gitea webhook: raw body for X-Gitea-Signature verification (must be before JSON parser)
// Limit 2mb so large Gitea payloads (full repo JSON) don't get rejected and cause 502
app.use('/api/webhook', express.raw({ type: 'application/json', limit: '2mb' }))
app.use('/api/webhook', webhookRoutes)
// Body parsing (BEFORE routes, AFTER static) // Body parsing (BEFORE routes, AFTER static)
// Note: Stripe webhook needs raw body, handled in stripe routes // Note: Stripe webhook needs raw body, handled in stripe routes
app.use('/api', express.json({ limit: '1mb' })) app.use('/api', express.json({ limit: '1mb' }))
@@ -84,6 +90,19 @@ app.use('/api', apiRoutes)
// Preferences endpoints (inline for simplicity) // Preferences endpoints (inline for simplicity)
import { userPreferences } from './services/database.mjs' import { userPreferences } from './services/database.mjs'
import { isAdmin } from './config/index.mjs'
/**
* GET /api/me?email=xxx
* Returns current user context (e.g. isAdmin) for the given email.
*/
app.get('/api/me', asyncHandler(async (req, res) => {
const { email } = req.query
if (!email || typeof email !== 'string') {
throw new ValidationError('email is required')
}
respond.success(res, { isAdmin: isAdmin(email) })
}))
app.get('/api/preferences', asyncHandler(async (req, res) => { app.get('/api/preferences', asyncHandler(async (req, res) => {
const { userId } = req.query const { userId } = req.query
@@ -207,6 +226,69 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res)
respond.success(res, null, 'Company label deleted') respond.success(res, null, 'Company label deleted')
})) }))
/**
* GET /api/preferences/name-labels
* Get name labels (worker labels). Admin only.
*/
app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
const { userId, email } = req.query
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
respond.success(res, preferences.nameLabels || [])
}))
/**
* POST /api/preferences/name-labels
* Save/Update name label (worker). Admin only.
*/
app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
const { userId, email, nameLabel } = req.body
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
if (!nameLabel) throw new ValidationError('nameLabel is required')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
const nameLabels = preferences.nameLabels || []
if (!nameLabel.id) {
nameLabel.id = `namelabel_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
const existingIndex = nameLabels.findIndex(l => l.id === nameLabel.id)
if (existingIndex >= 0) {
nameLabels[existingIndex] = nameLabel
} else {
nameLabels.push(nameLabel)
}
await userPreferences.upsert(userId, { nameLabels })
respond.success(res, nameLabel, 'Name label saved')
}))
/**
* DELETE /api/preferences/name-labels/:id
* Delete name label. Admin only.
*/
app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => {
const { userId, email } = req.query
const { id } = req.params
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
if (!id) throw new ValidationError('label id is required')
const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults()
const nameLabels = (preferences.nameLabels || []).filter(l => l.id !== id)
await userPreferences.upsert(userId, { nameLabels })
respond.success(res, null, 'Name label deleted')
}))
// Legacy Stripe webhook endpoint // Legacy Stripe webhook endpoint
app.use('/stripe', stripeRoutes) app.use('/stripe', stripeRoutes)

View File

@@ -233,6 +233,12 @@
"zod-to-json-schema": "^3.24.1" "zod-to-json-schema": "^3.24.1"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.0.8", "version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
@@ -242,6 +248,17 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -270,6 +287,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -555,6 +581,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -1028,12 +1063,54 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/imapflow": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1160,6 +1237,42 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1360,6 +1473,15 @@
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1381,6 +1503,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1421,6 +1552,59 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1459,6 +1643,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1483,6 +1673,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1513,6 +1712,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1667,6 +1875,39 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1677,6 +1918,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1706,6 +1956,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "7.0.19", "version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",

263
server/package-lock.json generated
View File

@@ -16,6 +16,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"google-auth-library": "^9.14.2", "google-auth-library": "^9.14.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"imapflow": "^1.2.8",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"stripe": "^17.4.0" "stripe": "^17.4.0"
}, },
@@ -255,6 +256,12 @@
"zod-to-json-schema": "^3.24.1" "zod-to-json-schema": "^3.24.1"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.0.8", "version": "25.0.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
@@ -264,6 +271,17 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -292,6 +310,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -577,6 +604,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@@ -1050,12 +1086,54 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/imapflow": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.8.tgz",
"integrity": "sha512-ym7FF2tKOlOzfRvxehs4eLkhjP8Mme3sSp2tcxEbyoeJuJwtEWxaVDv12+DnaMG2LXm0zuQGWZiClq31FLPUNg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "7.0.13",
"pino": "10.3.0",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1182,6 +1260,42 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -1382,6 +1496,15 @@
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1403,6 +1526,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -1443,6 +1575,59 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pino": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.0.tgz",
"integrity": "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1481,6 +1666,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -1505,6 +1696,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -1535,6 +1735,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -1689,6 +1898,39 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1699,6 +1941,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1728,6 +1979,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tldts": { "node_modules/tldts": {
"version": "7.0.19", "version": "7.0.19",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz",

View File

@@ -16,6 +16,7 @@
"test": "node e2e-test.mjs", "test": "node e2e-test.mjs",
"test:frontend": "node test-frontend.mjs", "test:frontend": "node test-frontend.mjs",
"verify": "node verify-setup.mjs", "verify": "node verify-setup.mjs",
"create-admin": "node scripts/create-admin-user.mjs",
"cleanup": "node cleanup.mjs", "cleanup": "node cleanup.mjs",
"lint": "eslint --ext .mjs ." "lint": "eslint --ext .mjs ."
}, },
@@ -38,6 +39,7 @@
"express": "^4.21.2", "express": "^4.21.2",
"google-auth-library": "^9.14.2", "google-auth-library": "^9.14.2",
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"imapflow": "^1.2.8",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"stripe": "^17.4.0" "stripe": "^17.4.0"
}, },

View File

@@ -78,12 +78,20 @@ router.post('/connect',
validate({ validate({
body: { body: {
userId: [rules.required('userId')], userId: [rules.required('userId')],
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])], provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
email: [rules.required('email'), rules.email()], email: [rules.required('email'), rules.email()],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body
// IMAP: require password (or accessToken as password)
if (provider === 'imap') {
const imapPassword = password || accessToken
if (!imapPassword) {
throw new ValidationError('IMAP account requires a password or app password', { password: ['Required for IMAP'] })
}
}
// Check if account already exists // Check if account already exists
const existingAccounts = await emailAccounts.getByUser(userId) const existingAccounts = await emailAccounts.getByUser(userId)
@@ -95,17 +103,44 @@ router.post('/connect',
}) })
} }
// IMAP: verify connection before saving
if (provider === 'imap') {
const { ImapService } = await import('../services/imap.mjs')
const imapPassword = password || accessToken
const imap = new ImapService({
host: imapHost || 'imap.porkbun.com',
port: imapPort != null ? Number(imapPort) : 993,
secure: imapSecure !== false,
user: email,
password: imapPassword,
})
try {
await imap.connect()
await imap.listEmails(1)
await imap.close()
} catch (err) {
log.warn('IMAP connection test failed', { email, error: err.message })
throw new ValidationError('IMAP connection failed. Check email and password (use app password if 2FA is on).', { password: [err.message || 'Connection failed'] })
}
}
// Create account // Create account
const account = await emailAccounts.create({ const accountData = {
userId, userId,
provider, provider,
email, email,
accessToken: accessToken || '', accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''),
refreshToken: refreshToken || '', refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
expiresAt: expiresAt || 0, expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
isActive: true, isActive: true,
lastSync: null, lastSync: null,
}) }
if (provider === 'imap') {
if (imapHost != null) accountData.imapHost = String(imapHost)
if (imapPort != null) accountData.imapPort = Number(imapPort)
if (imapSecure !== undefined) accountData.imapSecure = Boolean(imapSecure)
}
const account = await emailAccounts.create(accountData)
log.success(`Email account connected: ${email} (${provider})`) log.success(`Email account connected: ${email} (${provider})`)
@@ -487,6 +522,24 @@ router.post('/sort',
} }
} }
// Create name labels (workers) personal labels per team member
const nameLabelMap = {}
if (preferences.nameLabels?.length) {
for (const nl of preferences.nameLabels) {
if (!nl.enabled) continue
try {
const labelName = `EmailSorter/Team/${nl.name}`
const label = await gmail.createLabel(labelName, '#4a86e8')
if (label) {
nameLabelMap[nl.id || nl.name] = label.id
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = label.id
}
} catch (err) {
log.warn(`Failed to create name label: ${nl.name}`)
}
}
}
// Fetch and process ALL emails with pagination // Fetch and process ALL emails with pagination
let pageToken = null let pageToken = null
let totalProcessed = 0 let totalProcessed = 0
@@ -518,6 +571,7 @@ router.post('/sort',
let category = null let category = null
let companyLabel = null let companyLabel = null
let assignedTo = null
let skipAI = false let skipAI = false
// PRIORITY 1: Check custom company labels // PRIORITY 1: Check custom company labels
@@ -548,6 +602,7 @@ router.post('/sort',
if (!skipAI) { if (!skipAI) {
const classification = await sorter.categorize(emailData, preferences) const classification = await sorter.categorize(emailData, preferences)
category = classification.category category = classification.category
assignedTo = classification.assignedTo || null
// If category is disabled, fallback to review // If category is disabled, fallback to review
if (!enabledCategories.includes(category)) { if (!enabledCategories.includes(category)) {
@@ -559,6 +614,7 @@ router.post('/sort',
email, email,
category, category,
companyLabel, companyLabel,
assignedTo,
}) })
// Collect samples for suggested rules (first run only, max 50) // Collect samples for suggested rules (first run only, max 50)
@@ -573,7 +629,7 @@ router.post('/sort',
} }
// Apply labels/categories and actions // Apply labels/categories and actions
for (const { email, category, companyLabel } of processedEmails) { for (const { email, category, companyLabel, assignedTo } of processedEmails) {
const action = sorter.getCategoryAction(category, preferences) const action = sorter.getCategoryAction(category, preferences)
try { try {
@@ -585,6 +641,11 @@ router.post('/sort',
labelsToAdd.push(companyLabelMap[companyLabel]) labelsToAdd.push(companyLabelMap[companyLabel])
} }
// Add name label (worker) if AI assigned email to a person
if (assignedTo && nameLabelMap[assignedTo]) {
labelsToAdd.push(nameLabelMap[assignedTo])
}
// Add category label/category // Add category label/category
if (labelMap[category]) { if (labelMap[category]) {
labelsToAdd.push(labelMap[category]) labelsToAdd.push(labelMap[category])
@@ -794,6 +855,160 @@ router.post('/sort',
throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`) throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`)
} }
} }
// ═══════════════════════════════════════════════════════════════════════
// IMAP (Porkbun, Nextcloud mail backend, etc.)
// ═══════════════════════════════════════════════════════════════════════
else if (account.provider === 'imap') {
if (!features.ai()) {
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
}
if (!account.accessToken) {
throw new ValidationError('IMAP account needs to be reconnected (password missing)')
}
log.info(`IMAP sorting started for ${account.email}`)
const { ImapService, getFolderNameForCategory } = await import('../services/imap.mjs')
const imap = new ImapService({
host: account.imapHost || 'imap.porkbun.com',
port: account.imapPort != null ? account.imapPort : 993,
secure: account.imapSecure !== false,
user: account.email,
password: account.accessToken,
})
try {
await imap.connect()
const enabledCategories = sorter.getEnabledCategories(preferences)
// Name labels (workers): create Team subfolders for IMAP/Nextcloud
const nameLabelMap = {}
if (preferences.nameLabels?.length) {
for (const nl of preferences.nameLabels) {
if (!nl.enabled) continue
const folderName = `Team/${nl.name}`
try {
await imap.ensureFolder(folderName)
nameLabelMap[nl.id || nl.name] = folderName
if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = folderName
} catch (err) {
log.warn(`IMAP name label folder failed: ${nl.name}`, { error: err.message })
}
}
}
let pageToken = null
let totalProcessed = 0
const batchSize = 100
do {
const { messages, nextPageToken } = await imap.listEmails(batchSize, pageToken)
pageToken = nextPageToken
if (!messages?.length) break
const emails = await imap.batchGetEmails(messages.map((m) => m.id))
const processedEmails = []
for (const email of emails) {
const emailData = {
from: email.headers?.from || '',
subject: email.headers?.subject || '',
snippet: email.snippet || '',
}
let category = null
let companyLabel = null
let assignedTo = null
let skipAI = false
if (preferences.companyLabels?.length) {
for (const companyLabelConfig of preferences.companyLabels) {
if (!companyLabelConfig.enabled) continue
if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) {
category = companyLabelConfig.category || 'promotions'
companyLabel = companyLabelConfig.name
skipAI = true
break
}
}
}
if (!skipAI && preferences.autoDetectCompanies) {
const detected = sorter.detectCompany(emailData)
if (detected) {
category = 'promotions'
companyLabel = detected.label
skipAI = true
}
}
if (!skipAI) {
const classification = await sorter.categorize(emailData, preferences)
category = classification.category
assignedTo = classification.assignedTo || null
if (!enabledCategories.includes(category)) category = 'review'
}
processedEmails.push({ email, category, companyLabel, assignedTo })
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: emailData.from,
subject: emailData.subject,
snippet: emailData.snippet,
category,
})
}
}
const actionMap = sorter.getCategoryAction ? (cat) => sorter.getCategoryAction(cat, preferences) : () => 'inbox'
for (const { email, category, companyLabel, assignedTo } of processedEmails) {
try {
const action = actionMap(category)
// If AI assigned to a worker, move to Team/<Name> folder; else use category folder
const folderName = (assignedTo && nameLabelMap[assignedTo])
? nameLabelMap[assignedTo]
: getFolderNameForCategory(companyLabel ? (preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions') : category)
await imap.moveToFolder(email.id, folderName)
if (action === 'archive_read') {
try {
await imap.markAsRead(email.id)
} catch {
// already moved; mark as read optional
}
}
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
} catch (err) {
log.warn(`IMAP sort failed: ${email.id}`, { error: err.message })
}
}
totalProcessed += emails.length
log.info(`IMAP processed ${totalProcessed} emails so far...`)
if (totalProcessed >= effectiveMax) break
if (pageToken) await new Promise((r) => setTimeout(r, 200))
} while (pageToken && processAll)
await imap.close()
log.success(`IMAP sorting completed: ${sortedCount} emails processed`)
} catch (err) {
try {
await imap.close()
} catch {
// ignore
}
log.error('IMAP sorting failed', { error: err.message })
throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`)
}
}
// Update last sync // Update last sync
await emailAccounts.updateLastSync(accountId) await emailAccounts.updateLastSync(accountId)

125
server/routes/webhook.mjs Normal file
View File

@@ -0,0 +1,125 @@
/**
* Webhook Routes (Gitea etc.)
* Production: https://emailsorter.webklar.com/api/webhook/gitea
* POST /api/webhook/gitea - Deployment on push (validates Bearer or X-Gitea-Signature)
*/
import express from 'express'
import crypto from 'crypto'
import { asyncHandler, AuthorizationError } from '../middleware/errorHandler.mjs'
import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
const router = express.Router()
const secret = config.gitea.webhookSecret
const authToken = config.gitea.webhookAuthToken
/**
* Validate Gitea webhook request:
* - Authorization: Bearer <secret|authToken> (Gitea 1.19+ or manual calls)
* - X-Gitea-Signature: HMAC-SHA256 hex of raw body (Gitea default)
*/
function validateGiteaWebhook(req) {
const rawBody = req.body
if (!rawBody || !Buffer.isBuffer(rawBody)) {
throw new AuthorizationError('Raw body fehlt (Webhook-Route muss vor JSON-Parser registriert sein)')
}
// 1) Bearer token (Header)
const authHeader = req.get('Authorization')
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.slice(7).trim()
const expected = authToken || secret
if (expected && token === expected) {
return true
}
}
// 2) X-Gitea-Signature (HMAC-SHA256 hex)
const signatureHeader = req.get('X-Gitea-Signature')
if (signatureHeader && secret) {
try {
const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const received = signatureHeader.trim()
const receivedHex = received.startsWith('sha256=') ? received.slice(7) : received
if (expectedHex.length === receivedHex.length && expectedHex.length > 0) {
const a = Buffer.from(expectedHex, 'hex')
const b = Buffer.from(receivedHex, 'hex')
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true
}
} catch (_) {
// invalid hex or comparison error fall through to reject
}
}
if (!secret && !authToken) {
throw new AuthorizationError('GITEA_WEBHOOK_SECRET nicht konfiguriert')
}
throw new AuthorizationError('Ungültige Webhook-Signatur oder fehlender Authorization-Header')
}
/**
* POST /api/webhook/gitea
* Gitea push webhook validates Bearer or X-Gitea-Signature, then accepts event
*/
router.post('/gitea', asyncHandler(async (req, res) => {
try {
validateGiteaWebhook(req)
} catch (err) {
if (err.name === 'AuthorizationError' || err.statusCode === 401) throw err
log.error('Gitea Webhook: Validierung fehlgeschlagen', { error: err.message })
return res.status(401).json({ error: 'Webhook validation failed' })
}
let payload
try {
const raw = req.body && typeof req.body.toString === 'function' ? req.body.toString('utf8') : ''
payload = raw ? JSON.parse(raw) : {}
} catch (e) {
log.warn('Gitea Webhook: ungültiges JSON', { error: e.message })
return res.status(400).json({ error: 'Invalid JSON body' })
}
const ref = payload.ref || ''
const branch = ref.replace(/^refs\/heads\//, '')
const event = req.get('X-Gitea-Event') || 'push'
log.info('Gitea Webhook empfangen', { ref, branch, event })
// Optional: trigger deploy script in background (do not block response)
setImmediate(async () => {
try {
const { spawn } = await import('child_process')
const { fileURLToPath } = await import('url')
const { dirname, join } = await import('path')
const { existsSync } = await import('fs')
const __dirname = dirname(fileURLToPath(import.meta.url))
const deployScript = join(__dirname, '..', '..', 'scripts', 'deploy-to-server.mjs')
if (existsSync(deployScript)) {
const child = spawn('node', [deployScript], {
cwd: join(__dirname, '..', '..'),
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
})
child.unref()
child.stdout?.on('data', (d) => log.info('Deploy stdout:', d.toString().trim()))
child.stderr?.on('data', (d) => log.warn('Deploy stderr:', d.toString().trim()))
}
} catch (_) {}
})
res.status(202).json({ received: true, ref, branch })
}))
/**
* GET /api/webhook/status
* Simple status for webhook endpoint (e.g. health check)
*/
router.get('/status', (req, res) => {
res.json({
ok: true,
webhook: 'gitea',
configured: Boolean(secret || authToken),
})
})
export default router

View File

@@ -0,0 +1,75 @@
/**
* Create admin user in Appwrite (e.g. support@webklar.com).
* Requires: APPWRITE_* env vars. Optionally ADMIN_INITIAL_PASSWORD (otherwise one is generated).
* After creation, add the email to ADMIN_EMAILS in .env so the backend treats them as admin.
*
* Usage: node scripts/create-admin-user.mjs [email]
* Default email: support@webklar.com
*/
import 'dotenv/config'
import { Client, Users, ID } from 'node-appwrite'
const ADMIN_EMAIL = process.argv[2] || 'support@webklar.com'
const ADMIN_NAME = 'Support (Admin)'
const required = ['APPWRITE_ENDPOINT', 'APPWRITE_PROJECT_ID', 'APPWRITE_API_KEY']
for (const k of required) {
if (!process.env[k]) {
console.error(`Missing env: ${k}`)
process.exit(1)
}
}
let password = process.env.ADMIN_INITIAL_PASSWORD
if (!password || password.length < 8) {
const bytes = new Uint8Array(12)
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.getRandomValues) {
globalThis.crypto.getRandomValues(bytes)
} else {
const { randomFillSync } = await import('node:crypto')
randomFillSync(bytes)
}
password =
Array.from(bytes).map((b) => 'abcdefghjkmnpqrstuvwxyz23456789'[b % 32]).join('') + 'A1!'
console.log('No ADMIN_INITIAL_PASSWORD set using generated password (save it!):')
console.log(' ' + password)
console.log('')
}
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT_ID)
.setKey(process.env.APPWRITE_API_KEY)
const users = new Users(client)
async function main() {
try {
const existing = await users.list([], ADMIN_EMAIL)
const found = existing.users?.find((u) => u.email?.toLowerCase() === ADMIN_EMAIL.toLowerCase())
if (found) {
console.log(`User already exists: ${ADMIN_EMAIL} (ID: ${found.$id})`)
console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL)
return
}
const user = await users.create(ID.unique(), ADMIN_EMAIL, undefined, password, ADMIN_NAME)
console.log('Admin user created:')
console.log(' Email:', user.email)
console.log(' ID:', user.$id)
console.log(' Name:', user.name)
console.log('')
console.log('Add to server/.env: ADMIN_EMAILS=' + ADMIN_EMAIL)
console.log('Then the backend will treat this user as admin (isAdmin() returns true).')
} catch (err) {
console.error('Error:', err.message || err)
if (err.code === 409) {
console.error('User with this email may already exist. Check Appwrite Console → Auth → Users.')
}
process.exit(1)
}
}
main()

View File

@@ -417,7 +417,8 @@ Subject: ${subject}
Preview: ${snippet?.substring(0, 500) || 'No preview'} Preview: ${snippet?.substring(0, 500) || 'No preview'}
RESPONSE FORMAT (JSON ONLY): RESPONSE FORMAT (JSON ONLY):
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"} {"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation", "assignedTo": "name_label_id_or_name_or_null"}
If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subject/body mentions them), set assignedTo to that worker's id or name. Otherwise set assignedTo to null or omit it.
Respond ONLY with the JSON object.` Respond ONLY with the JSON object.`
@@ -438,6 +439,15 @@ Respond ONLY with the JSON object.`
result.category = 'review' result.category = 'review'
} }
// Validate assignedTo against name labels (id or name)
if (result.assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
l => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
)
if (!match) result.assignedTo = null
else result.assignedTo = match.id || match.name
}
return result return result
} catch (error) { } catch (error) {
log.error('AI categorization failed', { error: error.message }) log.error('AI categorization failed', { error: error.message })
@@ -484,7 +494,8 @@ EMAILS:
${emailList} ${emailList}
RESPONSE FORMAT (JSON ARRAY ONLY): RESPONSE FORMAT (JSON ARRAY ONLY):
[{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...] [{"index": 0, "category": "key", "assignedTo": "id_or_name_or_null"}, ...]
If an email is clearly FOR a specific worker, set assignedTo to that worker's id or name. Otherwise omit or null.
Respond ONLY with the JSON array.` Respond ONLY with the JSON array.`
@@ -515,9 +526,16 @@ Respond ONLY with the JSON array.`
return emails.map((email, i) => { return emails.map((email, i) => {
const result = parsed.find(r => r.index === i) const result = parsed.find(r => r.index === i)
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review' const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
let assignedTo = result?.assignedTo || null
if (assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
l => l.enabled && (l.id === assignedTo || l.name === assignedTo)
)
assignedTo = match ? (match.id || match.name) : null
}
return { return {
email, email,
classification: { category, confidence: 0.8, reason: 'Batch' }, classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
} }
}) })
} catch (error) { } catch (error) {
@@ -578,6 +596,14 @@ Respond ONLY with the JSON array.`
} }
} }
// Name labels (workers) assign email to a person when clearly for them
if (preferences.nameLabels?.length) {
const activeNameLabels = preferences.nameLabels.filter(l => l.enabled)
if (activeNameLabels.length > 0) {
parts.push(`NAME LABELS (workers) assign email to ONE person when the email is clearly FOR that person (e.g. "für Max", "an Anna", "Max bitte prüfen", subject/body mentions them):\n${activeNameLabels.map(l => `- id: "${l.id}", name: "${l.name}"${l.keywords?.length ? `, keywords: ${JSON.stringify(l.keywords)}` : ''}`).join('\n')}\nIf the email is for a specific worker, set "assignedTo" to that label's id or name. Otherwise omit assignedTo.`)
}
}
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : '' return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
} }

View File

@@ -373,6 +373,7 @@ export const userPreferences = {
enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'], enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'],
categoryActions: {}, categoryActions: {},
companyLabels: [], companyLabels: [],
nameLabels: [],
autoDetectCompanies: true, autoDetectCompanies: true,
version: 1, version: 1,
categoryAdvanced: {}, categoryAdvanced: {},
@@ -410,6 +411,7 @@ export const userPreferences = {
enabledCategories: preferences.enabledCategories || defaults.enabledCategories, enabledCategories: preferences.enabledCategories || defaults.enabledCategories,
categoryActions: preferences.categoryActions || defaults.categoryActions, categoryActions: preferences.categoryActions || defaults.categoryActions,
companyLabels: preferences.companyLabels || defaults.companyLabels, companyLabels: preferences.companyLabels || defaults.companyLabels,
nameLabels: preferences.nameLabels || defaults.nameLabels,
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies, autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
} }
}, },

181
server/services/imap.mjs Normal file
View File

@@ -0,0 +1,181 @@
/**
* IMAP Service
* Generic IMAP (e.g. Porkbun, Nextcloud mail backend) connect, list, fetch, move to folder
*/
import { ImapFlow } from 'imapflow'
import { log } from '../middleware/logger.mjs'
const INBOX = 'INBOX'
const FOLDER_PREFIX = 'EmailSorter'
/** Map category key to IMAP folder name */
export function getFolderNameForCategory(category) {
const map = {
vip: 'VIP',
customers: 'Clients',
invoices: 'Invoices',
newsletters: 'Newsletters',
promotions: 'Promotions',
social: 'Social',
security: 'Security',
calendar: 'Calendar',
review: 'Review',
archive: 'Archive',
}
return map[category] || 'Review'
}
/**
* IMAP Service same conceptual interface as GmailService/OutlookService
*/
export class ImapService {
/**
* @param {object} opts
* @param {string} opts.host - e.g. imap.porkbun.com
* @param {number} opts.port - e.g. 993
* @param {boolean} opts.secure - true for SSL/TLS
* @param {string} opts.user - email address
* @param {string} opts.password - app password
*/
constructor(opts) {
const { host, port = 993, secure = true, user, password } = opts
this.client = new ImapFlow({
host: host || 'imap.porkbun.com',
port: port || 993,
secure: secure !== false,
auth: { user, pass: password },
logger: false,
})
this.lock = null
}
async connect() {
await this.client.connect()
}
async close() {
try {
if (this.lock) await this.lock.release().catch(() => {})
await this.client.logout()
} catch {
this.client.close()
}
}
/**
* List messages from INBOX (returns ids = UIDs for use with getEmail/batchGetEmails)
* @param {number} maxResults
* @param {string|null} _pageToken - reserved for future pagination
*/
async listEmails(maxResults = 50, _pageToken = null) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const uids = await this.client.search({ all: true }, { uid: true })
const slice = uids.slice(0, maxResults)
const nextPageToken = uids.length > maxResults ? String(slice[slice.length - 1]) : null
return {
messages: slice.map((uid) => ({ id: String(uid) })),
nextPageToken,
}
} finally {
lock.release()
this.lock = null
}
}
/** Normalize ImapFlow message to same shape as Gmail/Outlook (id, headers.from, headers.subject, snippet) */
_normalize(msg) {
if (!msg || !msg.envelope) return null
const from = msg.envelope.from && msg.envelope.from[0] ? (msg.envelope.from[0].address || msg.envelope.from[0].name || '') : ''
const subject = msg.envelope.subject || ''
return {
id: String(msg.uid),
headers: { from, subject },
snippet: subject.slice(0, 200),
}
}
/**
* Get one message by id (UID string)
*/
async getEmail(messageId) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const list = await this.client.fetchAll(String(messageId), { envelope: true }, { uid: true })
return this._normalize(list && list[0])
} finally {
lock.release()
this.lock = null
}
}
/**
* Batch get multiple messages by id (UID strings) single lock, one fetch
*/
async batchGetEmails(messageIds) {
if (!messageIds.length) return []
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
const uids = messageIds.map((id) => (typeof id === 'string' ? Number(id) : id)).filter((n) => !Number.isNaN(n))
if (!uids.length) return []
const list = await this.client.fetchAll(uids, { envelope: true }, { uid: true })
return (list || []).map((m) => this._normalize(m)).filter(Boolean)
} catch (e) {
log.warn('IMAP batchGetEmails failed', { error: e.message })
return []
} finally {
lock.release()
this.lock = null
}
}
/**
* Ensure folder exists (create if not). Use subfolder under EmailSorter to avoid clutter.
*/
async ensureFolder(folderName) {
const path = `${FOLDER_PREFIX}/${folderName}`
try {
await this.client.mailboxCreate(path)
log.info(`IMAP folder created: ${path}`)
} catch (err) {
if (err.code !== 'ALREADYEXISTS' && !/already exists/i.test(err.message)) {
throw err
}
}
return path
}
/**
* Move message (by UID) from INBOX to folder name (under EmailSorter/)
*/
async moveToFolder(messageId, folderName) {
const path = `${FOLDER_PREFIX}/${folderName}`
await this.ensureFolder(folderName)
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
await this.client.messageMove(String(messageId), path, { uid: true })
} finally {
lock.release()
this.lock = null
}
}
/**
* Mark message as read (\\Seen)
*/
async markAsRead(messageId) {
const lock = await this.client.getMailboxLock(INBOX)
this.lock = lock
try {
await this.client.messageFlagsAdd(String(messageId), ['\\Seen'], { uid: true })
} finally {
lock.release()
this.lock = null
}
}
}