From 6fdf8f64367e7034ebd27ff0191c1ee0c12e94cd Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Fri, 23 Jan 2026 17:02:02 -0600 Subject: [PATCH] Blocked Sender --- backend/server.js | 41 ++++ frontend/src/App.jsx | 212 ++++++++++++++------- frontend/src/components/BlockedSenders.jsx | 156 +++++++++++++++ frontend/src/services/api.js | 12 ++ 4 files changed, 351 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/BlockedSenders.jsx diff --git a/backend/server.js b/backend/server.js index 665d328..c4c7ef8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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}`)); \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b53dab8..7af7bd4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { )} - {/* Access Denied / Invalid State */} + {/* Access Denied / Invalid State (nur wenn nicht authentifiziert und keine Regel geladen) */} {!isAuthenticating && !currentRule && (
-
- - - -
-

- Access Denied -

-

- This application can only be accessed via the secure link provided in your Roundcube configuration. -

+ {/* 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. */ +
+ {/* Suchfeld (Admin Modus) */} +
+

Admin Search

+
+ setEmail(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Enter email address..." + className="input-field" + /> + +
+ {emailError &&

{emailError}

} +
+
+ )}
)} {/* Configuration Tabs */} {currentRule && ( -
+

@@ -242,21 +299,32 @@ function App() { {/* Tabs */}
-
@@ -268,6 +336,10 @@ function App() { {activeTab === 'forwarding' && ( )} + {/* NEU: Blocked Content */} + {activeTab === 'blocked' && blockedRule && ( + + )}

)} @@ -287,4 +359,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/BlockedSenders.jsx b/frontend/src/components/BlockedSenders.jsx new file mode 100644 index 0000000..5fa6625 --- /dev/null +++ b/frontend/src/components/BlockedSenders.jsx @@ -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 ( +
+ {/* Add Pattern Form */} +
+ +
+
+ { + setNewPattern(e.target.value); + setInputError(''); + }} + onKeyPress={handleKeyPress} + placeholder="spam@*.com, *@badsite.org" + className={`input-field ${inputError ? 'border-red-500 focus:ring-red-500' : ''}`} + /> + {inputError && ( +

{inputError}

+ )} +
+ +
+

+ Supports wildcards (*). Examples: marketing@spam.com, *@phishing.net +

+
+ + {/* Blocked List */} +
+
+ +
+ + {patterns.length === 0 ? ( +
+ +

No blocked senders configured

+

Add a pattern above to block incoming emails

+
+ ) : ( +
+ {patterns.map((pattern, index) => ( +
+
+
+ +
+
+

{pattern}

+
+
+ +
+ ))} +
+ )} +
+ + {/* Save Button */} +
+ +
+
+ ); +}; + +export default BlockedSenders; \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 189a1be..69143f6 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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