diff --git a/src/anonchat/admin_routes.py b/src/anonchat/admin_routes.py
index 24ad5f7..32714dc 100644
--- a/src/anonchat/admin_routes.py
+++ b/src/anonchat/admin_routes.py
@@ -2,7 +2,9 @@ from flask import request, render_template, redirect, url_for, flash, session
from functools import wraps
from . import app, db, limiter, csrf
from .models import Inquiry, Message, Settings, Admin
-from .webhooks import WebhookError, inquiry_created
+from .notifiers import webhooks, emails
+from .notifiers.webhooks import WebhookError
+from .notifiers.emails import EmailNotificationError
def is_admin():
return 'admin_authenticated' in session and session['admin_authenticated']
@@ -84,33 +86,6 @@ def admin_dashboard():
return render_template('admin/dashboard.html', inquiries_with_data=inquiries_with_data)
-@app.route('/admin/settings/webhook', methods=['POST'])
-@admin_required
-def admin_settings_webhook():
- settings = Settings.query.first()
- if not settings:
- settings = Settings()
- db.session.add(settings)
-
- # Update webhook settings
- settings.webhook_enabled = request.form.get('webhook_enabled') == 'on'
- settings.webhook_url = request.form.get('webhook_url', '')
- settings.webhook_secret = request.form.get('webhook_secret', '')
-
- db.session.commit()
-
- # Test webhook if enabled and URL is provided
- if settings.webhook_enabled and settings.webhook_url:
- try:
- inquiry_created('1234abcd1234abcd', 'This is a test message', is_async=False)
- flash('Webhook settings saved and test sent successfully')
- except WebhookError as e:
- flash(f'Webhook test failed: {str(e)}')
- else:
- flash('Webhook settings saved')
-
- return redirect(url_for('admin_settings'))
-
@app.route('/admin/settings', methods=['GET'])
@admin_required
def admin_settings():
@@ -149,4 +124,91 @@ def admin_settings_password():
flash('Password updated successfully', 'success')
return redirect(url_for('admin_settings'))
+
+@app.route('/admin/settings/notification', methods=['POST'])
+@admin_required
+def admin_settings_notification():
+ settings = Settings.query.first()
+ if not settings:
+ settings = Settings()
+ db.session.add(settings)
+
+ settings.notification_show_message = request.form.get('notification_show_message') == 'on'
+
+ db.session.commit()
+
+ flash('Notification settings saved', 'success')
+ return redirect(url_for('admin_settings'))
+
+@app.route('/admin/settings/webhook', methods=['POST'])
+@admin_required
+def admin_settings_webhook():
+ settings = Settings.query.first()
+ if not settings:
+ settings = Settings()
+ db.session.add(settings)
+ # Update webhook settings
+ settings.webhook_enabled = request.form.get('webhook_enabled') == 'on'
+ settings.webhook_url = request.form.get('webhook_url', '')
+ settings.webhook_secret = request.form.get('webhook_secret', '')
+
+ db.session.commit()
+
+ # Test webhook if enabled and URL is provided
+ if settings.webhook_enabled and settings.webhook_url:
+ try:
+ webhooks.send_webhook(
+ event_type='inquiry_created',
+ data={
+ 'inquiry_id': '1234abcd1234abcd',
+ 'message': 'This is a test message'
+ },
+ is_async=False
+ )
+ flash('Webhook settings saved and test sent successfully')
+ except WebhookError as e:
+ flash(f'Webhook test failed: {str(e)}', 'error')
+ else:
+ flash('Webhook settings saved', 'success')
+
+ return redirect(url_for('admin_settings'))
+
+@app.route('/admin/settings/email', methods=['POST'])
+@admin_required
+def admin_settings_email():
+ settings = Settings.query.first()
+ if not settings:
+ settings = Settings()
+ db.session.add(settings)
+
+ settings.email_notifications_enabled = request.form.get('email_notifications_enabled') == 'on'
+ settings.smtp_server = request.form.get('smtp_server', '')
+ settings.smtp_use_ssl = request.form.get('smtp_use_ssl') == 'on'
+ settings.smtp_username = request.form.get('smtp_username', '')
+ settings.smtp_password = request.form.get('smtp_password', '')
+ settings.recipient_email = request.form.get('recipient_email', '')
+
+ db.session.commit()
+
+ if settings.email_notifications_enabled:
+ result = emails.verify_email_settings(settings)
+ if result['errors']:
+ flash(f'Email settings verification failed: {", ".join(result["errors"])}', 'error')
+ else:
+ try:
+ emails.send_email(
+ title='Inquiry #1234abcd1234abcd Created',
+ inquiry_id='1234abcd1234abcd',
+ data={
+ 'message': 'This is a test message'
+ },
+ is_async=False
+ )
+ flash('Email settings saved and test sent successfully')
+ except EmailNotificationError as e:
+ flash(f'Email test failed: {str(e)}', 'error')
+ else:
+ flash('Email notifications svaed', 'success')
+
+ return redirect(url_for('admin_settings'))
diff --git a/src/anonchat/migrations/add_email_settings.py b/src/anonchat/migrations/add_email_settings.py
new file mode 100644
index 0000000..27f6dee
--- /dev/null
+++ b/src/anonchat/migrations/add_email_settings.py
@@ -0,0 +1,48 @@
+from sqlalchemy import Column, Boolean, String, inspect
+from sqlalchemy.sql import text
+from flask import current_app
+
+def run_migration(db):
+ """Add email notification settings columns to the Settings table if they don't exist."""
+ current_app.logger.info("Running migration: add_email_settings")
+
+ # Check if the table exists first
+ inspector = inspect(db.engine)
+ if 'settings' not in inspector.get_table_names():
+ current_app.logger.info("Settings table doesn't exist yet, skipping migration")
+ return False
+
+ # Check existing columns
+ columns = [col['name'] for col in inspector.get_columns('settings')]
+
+ # Track if we need to commit changes
+ changes_made = False
+
+ # Dictionary of columns to add with their types
+ columns_to_add = {
+ 'email_notifications_enabled': 'BOOLEAN DEFAULT FALSE',
+ 'smtp_server': 'VARCHAR(255)',
+ 'smtp_use_ssl': 'BOOLEAN DEFAULT FALSE',
+ 'smtp_username': 'VARCHAR(255)',
+ 'smtp_password': 'VARCHAR(255)',
+ 'recipient_email': 'VARCHAR(255)',
+ 'notification_show_message': 'BOOLEAN DEFAULT FALSE'
+ }
+
+ # Add each column if it doesn't exist
+ for column_name, column_type in columns_to_add.items():
+ if column_name not in columns:
+ current_app.logger.info(f"Adding {column_name} column to settings table")
+ with db.engine.connect() as conn:
+ conn.execute(text(f"ALTER TABLE settings ADD COLUMN {column_name} {column_type}"))
+ conn.commit()
+ changes_made = True
+ else:
+ current_app.logger.info(f"{column_name} column already exists in settings table")
+
+ if changes_made:
+ current_app.logger.info("Migration completed successfully")
+ else:
+ current_app.logger.info("No changes needed, all columns already exist")
+
+ return changes_made
\ No newline at end of file
diff --git a/src/anonchat/models/settings.py b/src/anonchat/models/settings.py
index 679a59b..5a35765 100644
--- a/src/anonchat/models/settings.py
+++ b/src/anonchat/models/settings.py
@@ -2,6 +2,18 @@ from .. import db
class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True)
+
+ # Webhook settings
webhook_enabled = db.Column(db.Boolean, default=False)
webhook_url = db.Column(db.String(255), nullable=True)
- webhook_secret = db.Column(db.String(255), nullable=True)
\ No newline at end of file
+ webhook_secret = db.Column(db.String(255), nullable=True)
+
+ # Email notification settings
+ email_notifications_enabled = db.Column(db.Boolean, default=False)
+ smtp_server = db.Column(db.String(255), nullable=True)
+ smtp_use_ssl = db.Column(db.Boolean, default=False)
+ smtp_username = db.Column(db.String(255), nullable=True)
+ smtp_password = db.Column(db.String(255), nullable=True) # Consider storing this securely
+ recipient_email = db.Column(db.String(255), nullable=True)
+
+ notification_show_message = db.Column(db.Boolean, default=False)
\ No newline at end of file
diff --git a/src/anonchat/notifiers/__init__.py b/src/anonchat/notifiers/__init__.py
new file mode 100644
index 0000000..0541005
--- /dev/null
+++ b/src/anonchat/notifiers/__init__.py
@@ -0,0 +1,80 @@
+from flask import current_app
+
+from .emails import send_email
+from .webhooks import send_webhook
+MESSAGE_PREVIEW_LENGTH = 30
+EVENT_TYPES = {
+ 'inquiry_created': 'Inquiry #{} Created',
+ 'inquiry_reopened': 'Inquiry #{} Reopened',
+ 'inquiry_closed': 'Inquiry #{} Closed',
+ 'inquiry_message': 'Inquiry #{} Message'
+}
+
+### Abstract methods ###
+
+def inquiry_created(inquiry_id: str, message: str = None, is_async: bool = True) -> None:
+ _send_event(
+ event_type='inquiry_created',
+ inquiry_id=inquiry_id,
+ message=message,
+ is_async=is_async
+ )
+
+def inquiry_reopened(inquiry_id: str, is_async: bool = True) -> None:
+ _send_event(
+ event_type='inquiry_reopened',
+ inquiry_id=inquiry_id,
+ is_async=is_async
+ )
+
+
+def inquiry_closed(inquiry_id: str, is_async: bool = True) -> None:
+ _send_event(
+ event_type='inquiry_closed',
+ inquiry_id=inquiry_id,
+ is_async=is_async
+ )
+
+def inquiry_message(inquiry_id: str, message: str = None, is_async: bool = True) -> None:
+ _send_event(
+ event_type='inquiry_message',
+ inquiry_id=inquiry_id,
+ message=message,
+ is_async=is_async
+ )
+
+### Private methods ###
+
+def _send_event(event_type: str, inquiry_id: str, message: str = None, is_async: bool = True) -> None:
+ _send_email_ignore_errors(
+ title=EVENT_TYPES[event_type].format(inquiry_id),
+ inquiry_id=inquiry_id,
+ data={
+ 'message': _get_message_preview(message)
+ },
+ is_async=is_async
+ )
+
+ _send_webhook_ignore_errors(
+ event_type=event_type,
+ data={
+ 'inquiry_id': inquiry_id,
+ 'message': _get_message_preview(message)
+ },
+ is_async=is_async
+ )
+
+def _send_email_ignore_errors(title, inquiry_id, data, is_async=True):
+ try:
+ send_email(title, inquiry_id, data, is_async)
+ except:
+ pass
+
+def _send_webhook_ignore_errors(event_type, data, is_async=True):
+ try:
+ send_webhook(event_type, data, is_async)
+ except:
+ pass
+
+def _get_message_preview(message: str) -> str:
+ return (message[:MESSAGE_PREVIEW_LENGTH] + '...') if len(message) > MESSAGE_PREVIEW_LENGTH else message
\ No newline at end of file
diff --git a/src/anonchat/notifiers/emails.py b/src/anonchat/notifiers/emails.py
new file mode 100644
index 0000000..9477c13
--- /dev/null
+++ b/src/anonchat/notifiers/emails.py
@@ -0,0 +1,156 @@
+import smtplib
+from email.message import EmailMessage
+import threading
+from datetime import datetime
+from flask import current_app
+from ..models import Settings
+
+class EmailNotificationError(Exception):
+ """Exception raised for errors in the email notification process.
+
+ Attributes:
+ message -- explanation of the error
+ original_exception -- the original exception that caused this error (optional)
+ """
+
+ def __init__(self, message, original_exception=None):
+ self.message = message
+ self.original_exception = original_exception
+ super().__init__(self.message)
+
+
+def verify_email_settings(settings: Settings = None):
+ """
+ Verifies that email notification settings are properly configured.
+
+ Returns:
+ dict: A dictionary containing:
+ - 'valid' (bool): Whether settings are valid for sending emails
+ - 'errors' (list): List of error messages if any settings are invalid
+ - 'settings' (dict): Current settings state (without sensitive values)
+ """
+
+ if settings is None:
+ settings = Settings.query.first()
+
+ result = {
+ 'valid': False,
+ 'errors': [],
+ 'settings': {}
+ }
+
+ # Check if settings exist and notifications are enabled
+ if not settings:
+ result['errors'].append("Settings not found in database")
+ return result
+
+ result['settings']['email_notifications_enabled'] = settings.email_notifications_enabled
+ if not settings.email_notifications_enabled:
+ result['errors'].append("Email notifications are disabled")
+ return result
+
+ # Required fields
+ required_fields = {
+ 'smtp_server': settings.smtp_server,
+ 'smtp_username': settings.smtp_username,
+ 'recipient_email': settings.recipient_email
+ }
+
+ # Check required fields
+ for field, value in required_fields.items():
+ result['settings'][field] = value
+ if not value:
+ result['errors'].append(f"Missing required setting: {field}")
+
+ # Test SMTP connection if desired (commented out to avoid actual connection attempts)
+ # This could be implemented as a separate function that calls this one first
+
+ # Mark as valid if no errors
+ result['valid'] = len(result['errors']) == 0
+
+ return result
+
+def send_email(title: str, inquiry_id: str, data: dict = {}, is_async=False):
+ """Sends an email notification, either synchronously or asynchronously."""
+ app = current_app._get_current_object()
+
+ if is_async:
+ _send_email_async(app, title, inquiry_id, data)
+ else:
+ _send_email_worker(app, title, inquiry_id, data)
+
+def _send_email_async(app, title: str, inquiry_id: str, data: dict = {}):
+ """Queue email to be sent in a background thread"""
+ thread = threading.Thread(target=_send_email_worker, args=(app, title, inquiry_id, data))
+ thread.daemon = True # Thread will exit when main thread exits
+ thread.start()
+
+
+def _send_email_worker(app, title: str, inquiry_id: str, data: dict = {}):
+ """Worker function that sends the email notification."""
+ try:
+ with app.app_context():
+ settings = Settings.query.first()
+ if not settings or not settings.email_notifications_enabled:
+ return False
+
+ verification_result = verify_email_settings(settings)
+ if not verification_result['valid']:
+ raise EmailNotificationError(f"Invalid email settings: {', '.join(verification_result['errors'])}")
+
+ # Construct email message
+ msg = EmailMessage()
+ msg['Subject'] = "[AnonChat] " + title
+ msg['From'] = settings.smtp_username
+ msg['To'] = settings.recipient_email
+
+ content = f"""
+ Title: {title}
+ Timestamp: {datetime.utcnow().isoformat()} UTC
+ Inquiry ID: {inquiry_id}
+
+ {data.get('message', '') if settings.notification_show_message else ''}"""
+
+ msg.set_content(content)
+
+ _send_smtp_message(settings, msg)
+ app.logger.info(f"Email notification sent for title: {title}")
+ except Exception as e:
+ app.logger.error(f"Email error: {e}")
+
+ if isinstance(e, EmailNotificationError):
+ raise e
+
+ raise EmailNotificationError(f"Exception: {e}", original_exception=e)
+
+def _get_smtp_server(smtp_server: str):
+ smtp_server_parts = smtp_server.split(':')
+ smtp_host = smtp_server_parts[0]
+
+ if len(smtp_server_parts) > 1:
+ smtp_port = int(smtp_server_parts[1])
+ else:
+ smtp_port = 0
+
+ return (smtp_host, smtp_port)
+
+def _send_smtp_message(settings, msg):
+ smtp_server = None
+
+ smtp_host, smtp_port = _get_smtp_server(settings.smtp_server)
+
+ try:
+ if settings.smtp_use_ssl:
+ smtp_server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
+ else:
+ smtp_server = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
+
+ if settings.smtp_username and settings.smtp_password:
+ smtp_server.login(settings.smtp_username, settings.smtp_password)
+
+ smtp_server.send_message(msg)
+ except smtplib.SMTPException as e:
+ raise EmailNotificationError(f"SMTP Error: {e}", original_exception=e)
+ finally:
+ if smtp_server:
+ smtp_server.quit()
\ No newline at end of file
diff --git a/src/anonchat/webhooks.py b/src/anonchat/notifiers/webhooks.py
similarity index 79%
rename from src/anonchat/webhooks.py
rename to src/anonchat/notifiers/webhooks.py
index 7af5566..6f0e0a5 100644
--- a/src/anonchat/webhooks.py
+++ b/src/anonchat/notifiers/webhooks.py
@@ -4,10 +4,9 @@ import hmac
import hashlib
import json
import threading
-import logging
from datetime import datetime
from flask import current_app
-from .models import Settings
+from ..models import Settings
class WebhookError(Exception):
"""Exception raised for errors in the webhook process.
@@ -25,7 +24,7 @@ class WebhookError(Exception):
super().__init__(self.message)
-def _send_webhook(event_type, data, is_async=False):
+def send_webhook(event_type, data, is_async=False):
app = current_app._get_current_object()
if is_async:
@@ -85,29 +84,4 @@ def _send_webhook_worker(event_type, data, app):
if isinstance(e, WebhookError):
raise e
- raise WebhookError(f"Exception: {e}", original_exception=e)
-
-def inquiry_created(inquiry_id, message, is_async=True):
- _send_webhook('inquiry_created', {
- 'inquiry_id': inquiry_id,
- 'message': message
- }, is_async)
-
-def inquiry_reopened(inquiry_id, is_async=True):
- _send_webhook('inquiry_reopened', {
- 'inquiry_id': inquiry_id
- }, is_async)
-
-
-def inquiry_closed(inquiry_id, is_async=True):
- _send_webhook('inquiry_closed', {
- 'inquiry_id': inquiry_id
- }, is_async)
-
-def inquiry_message(inquiry_id, message, is_async=True):
- _send_webhook('inquiry_message', {
- 'inquiry_id': inquiry_id,
- 'message': message
- }, is_async)
-
-
\ No newline at end of file
+ raise WebhookError(f"Exception: {e}", original_exception=e)
\ No newline at end of file
diff --git a/src/anonchat/routes.py b/src/anonchat/routes.py
index ae22e61..20e1090 100644
--- a/src/anonchat/routes.py
+++ b/src/anonchat/routes.py
@@ -1,6 +1,6 @@
from flask import request, jsonify, render_template, redirect, url_for, flash, session, make_response
-from . import app, db, limiter, csrf, webhooks
-from .webhooks import WebhookError
+
+from . import app, db, limiter, csrf, notifiers
from .models import Inquiry, Message, Settings, Admin
import os
import json
@@ -79,11 +79,8 @@ def create_inquiry():
db.session.add(message)
db.session.commit()
- # Send webhook for new inquiry
- try:
- webhooks.inquiry_created(new_inquiry.id, initial_message)
- except WebhookError as e:
- app.logger.error(f"Error sending webhook for new inquiry: {str(e)}")
+ # Send notifications for new inquiry
+ notifiers.inquiry_created(new_inquiry.id, initial_message)
# Add to recent inquiries cookie
return redirect(url_for('inquiry', inquiry_id=new_inquiry.id))
@@ -101,11 +98,8 @@ def inquiry(inquiry_id):
inquiry.reopen()
db.session.commit()
- # Send webhook for reopened inquiry
- try:
- webhooks.inquiry_reopened(inquiry_id)
- except WebhookError as e:
- app.logger.error(f"Error sending webhook for reopened inquiry: {str(e)}")
+ # Send notifications for reopened inquiry
+ notifiers.inquiry_reopened(inquiry_id)
flash('Inquiry reopened successfully.')
@@ -121,10 +115,7 @@ def inquiry(inquiry_id):
db.session.commit()
if not is_admin_user: # Admins don't need to be notified of their own messages
- try:
- webhooks.inquiry_message(inquiry_id, message_content)
- except WebhookError as e:
- app.logger.error(f"Error sending webhook for message: {str(e)}")
+ notifiers.inquiry_message(inquiry_id, message_content)
response = redirect(url_for('inquiry', inquiry_id=inquiry_id))
return add_inquiry_to_cookie(response, inquiry_id) if not is_admin_user else response
@@ -237,11 +228,8 @@ def close_inquiry(inquiry_id):
inquiry.close()
db.session.commit()
- # Send webhook for closed inquiry
- try:
- webhooks.inquiry_closed(inquiry_id)
- except WebhookError as e:
- app.logger.error(f"Error sending webhook for closed inquiry: {str(e)}")
+ # Send notifications for closed inquiry
+ notifiers.inquiry_closed(inquiry_id)
# Redirect back to the inquiry or admin dashboard based on referrer
referrer = request.referrer
@@ -260,11 +248,8 @@ def reopen_inquiry(inquiry_id):
inquiry.reopen()
db.session.commit()
- # Send webhook for reopened inquiry
- try:
- webhooks.inquiry_reopened(inquiry_id)
- except WebhookError as e:
- app.logger.error(f"Error sending webhook for reopened inquiry: {str(e)}")
+ # Send notifications for reopened inquiry
+ notifiers.inquiry_reopened(inquiry_id)
flash('Inquiry reopened successfully.')
diff --git a/src/anonchat/static/css/styles.css b/src/anonchat/static/css/styles.css
index 6b40a9e..bc1ecb2 100644
--- a/src/anonchat/static/css/styles.css
+++ b/src/anonchat/static/css/styles.css
@@ -27,8 +27,6 @@ a:visited {
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
-main {
-}
footer {
font-size: small;
color: #999;
@@ -40,7 +38,7 @@ h1 {
form {
margin-bottom: 1rem;
}
-textarea, input[type="text"], input[type="password"] {
+textarea, input[type="text"], input[type="password"], input[type="email"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
diff --git a/src/anonchat/templates/admin/settings.html b/src/anonchat/templates/admin/settings.html
index c523f29..2aff75c 100644
--- a/src/anonchat/templates/admin/settings.html
+++ b/src/anonchat/templates/admin/settings.html
@@ -29,9 +29,29 @@
+
+
Configure webhook settings to receive notifications for new inquiries and responses.
@@ -84,6 +104,55 @@Configure email settings to receive notifications for new inquiries and responses.
+The following settings are configured through environment variables: