Blocked Sender

This commit is contained in:
Andreas Knuth 2026-01-23 17:02:02 -06:00
parent f6e9bfd2b7
commit 6fdf8f6436
4 changed files with 351 additions and 70 deletions

View File

@ -34,6 +34,7 @@ const client = new DynamoDBClient({
});
const docClient = DynamoDBDocumentClient.from(client);
const TABLE_NAME = process.env.DYNAMODB_TABLE || 'email-rules';
const TABLE_BLOCKED_NAME = process.env.DYNAMODB_TABLE_BLOCKED || 'email-blocked-senders';
// Validation
const handleValidationErrors = (req, res, next) => {
@ -101,4 +102,44 @@ app.post('/api/rules', [
}
});
// 5. Blocked: Get One
app.get('/api/blocked/:email', [
param('email').isEmail()
], handleValidationErrors, async (req, res) => {
try {
const response = await docClient.send(new GetCommand({
TableName: TABLE_BLOCKED_NAME,
Key: { email_address: decodeURIComponent(req.params.email) }
}));
// Wenn kein Eintrag existiert, geben wir ein leeres Standard-Objekt zurück
if (!response.Item) {
return res.json({
email_address: decodeURIComponent(req.params.email),
blocked_patterns: []
});
}
res.json(response.Item);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 6. Blocked: Save/Update
app.post('/api/blocked', [
body('email_address').isEmail(),
body('blocked_patterns').isArray()
], handleValidationErrors, async (req, res) => {
try {
const item = {
email_address: req.body.email_address,
blocked_patterns: req.body.blocked_patterns,
last_updated: new Date().toISOString(),
};
await docClient.send(new PutCommand({ TableName: TABLE_BLOCKED_NAME, Item: item }));
res.json({ success: true, rule: item });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, () => console.log(`🚀 API active on port ${PORT}`));

View File

@ -3,6 +3,7 @@ import { FiSearch, FiRefreshCw, FiTrash2, FiList } from 'react-icons/fi';
import Header from './components/Header';
import OutOfOffice from './components/OutOfOffice';
import Forwarding from './components/Forwarding';
import BlockedSenders from './components/BlockedSenders'; // Import der neuen Komponente
import Toast from './components/Toast';
import { emailRulesAPI } from './services/api';
@ -10,11 +11,13 @@ function App() {
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [currentRule, setCurrentRule] = useState(null);
const [blockedRule, setBlockedRule] = useState(null); // Neuer State für Blocked Senders
const [activeTab, setActiveTab] = useState('ooo');
const [isLoading, setIsLoading] = useState(false);
const [allRules, setAllRules] = useState([]);
const [showAllRules, setShowAllRules] = useState(false);
const [toast, setToast] = useState(null);
const [isAuthenticating, setIsAuthenticating] = useState(() => {
const params = new URLSearchParams(window.location.search);
return !!(params.get('email') && params.get('expires') && params.get('signature'));
@ -25,7 +28,59 @@ function App() {
setToast({ message, type });
};
// Check for authentication token in URL params on mount
// Hilfsfunktion: Lädt alle Daten (Regeln + Blocked) parallel
const loadAllData = async (emailAddress, isAuthFlow = false) => {
setIsLoading(true);
try {
// Parallel abrufen: Hauptregel und Blocked Senders
const [mainRule, blockedData] = await Promise.all([
// 1. Hauptregel abrufen
emailRulesAPI.getRule(emailAddress).catch(err => {
// 404 bei Hauptregel abfangen -> Default Objekt zurückgeben
if (err.statusCode === 404 || err.message.includes('not found') || err.message.includes('No rule exists')) {
return {
email_address: emailAddress,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
_isNew: true // Interner Marker für neue Regel
};
}
throw err;
}),
// 2. Blocked Senders abrufen
emailRulesAPI.getBlockedSenders(emailAddress).catch(() => {
// Fehler bei Blocked ignorieren (oder 404) -> Leeres Standard-Objekt
return { email_address: emailAddress, blocked_patterns: [] };
})
]);
setCurrentRule(mainRule);
setBlockedRule(blockedData);
// Toast Nachrichten steuern
if (isAuthFlow) {
if (mainRule._isNew) {
showToast('Welcome! You can now create email rules for your account.', 'success');
} else {
showToast('Authenticated successfully from Roundcube', 'success');
}
} else {
if (mainRule._isNew) {
showToast('No existing rule found. You can create a new one.', 'warning');
} else {
showToast('Email configuration loaded successfully', 'success');
}
}
} catch (error) {
showToast('Failed to load configuration: ' + error.message, 'error');
} finally {
setIsLoading(false);
}
};
// Auth-Check beim Start
useEffect(() => {
const checkAuthToken = async () => {
const params = new URLSearchParams(window.location.search);
@ -35,9 +90,9 @@ function App() {
if (emailParam && expiresParam && signatureParam) {
setIsAuthenticating(true);
setSingleEmailMode(true); // Enable single email mode
setSingleEmailMode(true);
try {
// Validate token
// Token validieren
const result = await emailRulesAPI.validateToken(
emailParam,
expiresParam,
@ -45,32 +100,15 @@ function App() {
);
if (result.success) {
// Token valid - auto-fill email and load rule
setEmail(emailParam);
// Clean URL (remove token parameters)
// URL bereinigen
window.history.replaceState({}, document.title, window.location.pathname);
// Auto-load the rule
const rule = await emailRulesAPI.getRule(emailParam);
setCurrentRule(rule);
showToast('Authenticated successfully from Roundcube', 'success');
// Alles laden (mit Flag isAuthFlow=true)
await loadAllData(emailParam, true);
}
} catch (error) {
if (error.statusCode === 404 || error.message.includes('not found') || error.message.includes('No rule exists')) {
// No rule exists yet - create empty template
setEmail(emailParam);
setCurrentRule({
email_address: emailParam,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
showToast('Welcome! You can now create email rules for your account.', 'success');
} else {
showToast('Authentication failed: ' + error.message, 'error');
}
} finally {
setIsAuthenticating(false);
}
@ -85,9 +123,9 @@ function App() {
return regex.test(email);
};
// Suche auslösen
const handleSearch = async () => {
const trimmedEmail = email.trim();
if (!trimmedEmail) {
setEmailError('Email address is required');
return;
@ -98,41 +136,22 @@ function App() {
return;
}
setIsLoading(true);
setEmailError('');
try {
const rule = await emailRulesAPI.getRule(trimmedEmail);
setCurrentRule(rule);
showToast('Email rule loaded successfully', 'success');
} catch (error) {
if (error.statusCode === 404 || error.message.includes('not found') || error.message.includes('No rule exists')) {
setCurrentRule({
email_address: trimmedEmail,
ooo_active: false,
ooo_message: '',
ooo_content_type: 'text',
forwards: [],
});
showToast('No existing rule found. You can create a new one.', 'warning');
} else {
showToast('Failed to fetch email rule: ' + error.message, 'error');
}
} finally {
setIsLoading(false);
}
// Alles laden (isAuthFlow=false)
await loadAllData(trimmedEmail, false);
};
// Update für Hauptregeln (OOO & Forwarding)
const handleUpdate = async (updates) => {
if (!currentRule) return;
try {
// _isNew entfernen, falls vorhanden
const { _isNew, ...restRule } = currentRule;
const updatedData = {
email_address: currentRule.email_address,
...currentRule,
...restRule,
...updates,
};
await emailRulesAPI.createOrUpdateRule(updatedData);
setCurrentRule(updatedData);
showToast('Email rule updated successfully', 'success');
@ -141,6 +160,17 @@ function App() {
}
};
// NEU: Update für Blocked Senders
const handleBlockedUpdate = async (updatedData) => {
try {
await emailRulesAPI.updateBlockedSenders(updatedData);
setBlockedRule(updatedData);
showToast('Blocked senders list updated', 'success');
} catch (error) {
showToast('Failed to update blocked list: ' + error.message, 'error');
}
};
const handleDelete = async () => {
if (!currentRule || !window.confirm(`Are you sure you want to delete the rule for ${currentRule.email_address}?`)) {
return;
@ -149,6 +179,7 @@ function App() {
try {
await emailRulesAPI.deleteRule(currentRule.email_address);
setCurrentRule(null);
setBlockedRule(null); // Auch Blocked resetten
setEmail('');
showToast('Email rule deleted successfully', 'success');
fetchAllRules();
@ -175,7 +206,13 @@ function App() {
const handleSelectRule = (ruleEmail) => {
setEmail(ruleEmail);
setShowAllRules(false);
handleSearch();
// Hier rufen wir direkt die Suche auf, der State 'email' updated async,
// daher nutzen wir den Parameter direkt
const event = { preventDefault: () => {} }; // Dummy event
// Wir müssen hier etwas vorsichtig sein, da setEmail async ist.
// Besser wäre es, handleSearch umzubauen, dass es Email akzeptiert,
// aber wir setzen es einfach und rufen loadAllData direkt auf.
loadAllData(ruleEmail, false);
};
useEffect(() => {
@ -200,26 +237,46 @@ function App() {
</div>
)}
{/* Access Denied / Invalid State */}
{/* Access Denied / Invalid State (nur wenn nicht authentifiziert und keine Regel geladen) */}
{!isAuthenticating && !currentRule && (
<div className="text-center py-16">
<div className="bg-red-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2">
Access Denied
</h3>
<p className="text-gray-600 max-w-md mx-auto mb-8">
This application can only be accessed via the secure link provided in your Roundcube configuration.
</p>
{/* Hier könnte man alternativ auch das Suchfeld anzeigen,
wenn man Standalone-Modus erlauben will.
Aktuell zeigt dein Code "Access Denied" an, wenn man nicht via Token kommt
und keine Regel geladen ist.
Falls du das Suchfeld willst, müsste dieser Block angepasst werden.
Ich lasse es wie im Original 'Access Denied', es sei denn SingleEmailMode ist an.
*/}
{!singleEmailMode && (
/* Falls Admin-Modus gewünscht: Suchfeld hier einfügen.
Ich behalte das Original-Verhalten bei. */
<div className="max-w-xl mx-auto">
{/* Suchfeld (Admin Modus) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h2 className="text-lg font-semibold mb-4">Admin Search</h2>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Enter email address..."
className="input-field"
/>
<button onClick={handleSearch} disabled={isLoading} className="btn-primary">
{isLoading ? <FiRefreshCw className="animate-spin" /> : <FiSearch />}
</button>
</div>
{emailError && <p className="text-red-500 text-sm mt-2">{emailError}</p>}
</div>
</div>
)}
</div>
)}
{/* Configuration Tabs */}
{currentRule && (
<div className="card">
<div className="card mt-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-bold text-gray-900">
@ -242,21 +299,32 @@ function App() {
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<nav className="flex gap-8">
<nav className="flex gap-8 overflow-x-auto">
<button
onClick={() => setActiveTab('ooo')}
className={`pb-3 px-1 transition-colors ${activeTab === 'ooo' ? 'tab-active' : 'tab-inactive'
}`}
className={`pb-3 px-1 transition-colors whitespace-nowrap ${
activeTab === 'ooo' ? 'tab-active' : 'tab-inactive'
}`}
>
Out of Office
</button>
<button
onClick={() => setActiveTab('forwarding')}
className={`pb-3 px-1 transition-colors ${activeTab === 'forwarding' ? 'tab-active' : 'tab-inactive'
}`}
className={`pb-3 px-1 transition-colors whitespace-nowrap ${
activeTab === 'forwarding' ? 'tab-active' : 'tab-inactive'
}`}
>
Email Forwarding
</button>
{/* NEU: Blocked Tab */}
<button
onClick={() => setActiveTab('blocked')}
className={`pb-3 px-1 transition-colors whitespace-nowrap ${
activeTab === 'blocked' ? 'tab-active' : 'tab-inactive'
}`}
>
Blocked Senders
</button>
</nav>
</div>
@ -268,6 +336,10 @@ function App() {
{activeTab === 'forwarding' && (
<Forwarding rule={currentRule} onUpdate={handleUpdate} />
)}
{/* NEU: Blocked Content */}
{activeTab === 'blocked' && blockedRule && (
<BlockedSenders rule={blockedRule} onUpdate={handleBlockedUpdate} />
)}
</div>
</div>
)}
@ -287,4 +359,4 @@ function App() {
);
}
export default App;
export default App;

View File

@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { FiSlash, FiPlus, FiTrash2, FiShield } from 'react-icons/fi';
const BlockedSenders = ({ rule, onUpdate }) => {
// 'rule' ist hier das Objekt { email_address, blocked_patterns: [] }
const [patterns, setPatterns] = useState(rule?.blocked_patterns || []);
const [newPattern, setNewPattern] = useState('');
const [inputError, setInputError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const validatePattern = (pattern) => {
// Einfache Validierung: Muss mindestens 3 Zeichen haben und darf nicht leer sein.
// Wildcards * sind erlaubt.
return pattern.length >= 3;
};
const handleAddPattern = () => {
const trimmedPattern = newPattern.trim();
if (!trimmedPattern) {
setInputError('Pattern is required');
return;
}
if (!validatePattern(trimmedPattern)) {
setInputError('Pattern too short or invalid');
return;
}
if (patterns.includes(trimmedPattern)) {
setInputError('This pattern is already in the list');
return;
}
setPatterns([...patterns, trimmedPattern]);
setNewPattern('');
setInputError('');
};
const handleRemovePattern = (patternToRemove) => {
setPatterns(patterns.filter(p => p !== patternToRemove));
};
const handleSave = async () => {
setIsLoading(true);
try {
await onUpdate({
email_address: rule.email_address,
blocked_patterns: patterns,
});
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddPattern();
}
};
return (
<div className="space-y-6">
{/* Add Pattern Form */}
<div>
<label htmlFor="block-pattern" className="block text-sm font-semibold text-gray-700 mb-2">
Block Sender Pattern
</label>
<div className="flex gap-2">
<div className="flex-1">
<input
id="block-pattern"
type="text"
value={newPattern}
onChange={(e) => {
setNewPattern(e.target.value);
setInputError('');
}}
onKeyPress={handleKeyPress}
placeholder="spam@*.com, *@badsite.org"
className={`input-field ${inputError ? 'border-red-500 focus:ring-red-500' : ''}`}
/>
{inputError && (
<p className="mt-1 text-sm text-red-600">{inputError}</p>
)}
</div>
<button
onClick={handleAddPattern}
className="btn-primary whitespace-nowrap bg-red-600 hover:bg-red-700 focus:ring-red-500"
>
<FiPlus className="w-4 h-4 mr-2 inline" />
Block
</button>
</div>
<p className="mt-2 text-xs text-gray-500">
Supports wildcards (*). Examples: <code>marketing@spam.com</code>, <code>*@phishing.net</code>
</p>
</div>
{/* Blocked List */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-semibold text-gray-700">
Blocked Patterns ({patterns.length})
</label>
</div>
{patterns.length === 0 ? (
<div className="text-center py-12 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg">
<FiSlash className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600 font-medium">No blocked senders configured</p>
<p className="text-sm text-gray-500 mt-1">Add a pattern above to block incoming emails</p>
</div>
) : (
<div className="space-y-2">
{patterns.map((pattern, index) => (
<div
key={index}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:border-red-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 bg-red-100 rounded-full">
<FiShield className="w-4 h-4 text-red-600" />
</div>
<div>
<p className="font-medium text-gray-900 font-mono text-sm">{pattern}</p>
</div>
</div>
<button
onClick={() => handleRemovePattern(pattern)}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Remove block"
>
<FiTrash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
{/* Save Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleSave}
disabled={isLoading}
className="btn-primary"
>
{isLoading ? 'Saving...' : 'Save Block List'}
</button>
</div>
</div>
);
};
export default BlockedSenders;

View File

@ -50,6 +50,18 @@ export const emailRulesAPI = {
const response = await api.delete(`/api/rules/${encodeURIComponent(email)}`);
return response.data;
},
// Get blocked senders
getBlockedSenders: async (email) => {
const response = await api.get(`/api/blocked/${encodeURIComponent(email)}`);
return response.data;
},
// Update blocked senders
updateBlockedSenders: async (data) => {
const response = await api.post('/api/blocked', data);
return response.data;
},
};
// Error handler