mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-07 03:59:48 -06:00
Merge pull request #164 from DRYTRIX/Feat-MailSupport
feat(admin): improve email configuration UI and logging
This commit is contained in:
@@ -42,6 +42,16 @@ class Settings(db.Model):
|
||||
# Privacy and analytics settings
|
||||
allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics
|
||||
|
||||
# Email configuration settings (stored in database, takes precedence over environment variables)
|
||||
mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config
|
||||
mail_server = db.Column(db.String(255), default='', nullable=True)
|
||||
mail_port = db.Column(db.Integer, default=587, nullable=True)
|
||||
mail_use_tls = db.Column(db.Boolean, default=True, nullable=True)
|
||||
mail_use_ssl = db.Column(db.Boolean, default=False, nullable=True)
|
||||
mail_username = db.Column(db.String(255), default='', nullable=True)
|
||||
mail_password = db.Column(db.String(255), default='', nullable=True) # Store encrypted in production
|
||||
mail_default_sender = db.Column(db.String(255), default='', nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
@@ -76,6 +86,16 @@ class Settings(db.Model):
|
||||
self.invoice_start_number = kwargs.get('invoice_start_number', 1000)
|
||||
self.invoice_terms = kwargs.get('invoice_terms', 'Payment is due within 30 days of invoice date.')
|
||||
self.invoice_notes = kwargs.get('invoice_notes', 'Thank you for your business!')
|
||||
|
||||
# Email configuration defaults
|
||||
self.mail_enabled = kwargs.get('mail_enabled', False)
|
||||
self.mail_server = kwargs.get('mail_server', '')
|
||||
self.mail_port = kwargs.get('mail_port', 587)
|
||||
self.mail_use_tls = kwargs.get('mail_use_tls', True)
|
||||
self.mail_use_ssl = kwargs.get('mail_use_ssl', False)
|
||||
self.mail_username = kwargs.get('mail_username', '')
|
||||
self.mail_password = kwargs.get('mail_password', '')
|
||||
self.mail_default_sender = kwargs.get('mail_default_sender', '')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Settings {self.id}>'
|
||||
@@ -108,6 +128,20 @@ class Settings(db.Model):
|
||||
logo_path = self.get_logo_path()
|
||||
return logo_path and os.path.exists(logo_path)
|
||||
|
||||
def get_mail_config(self):
|
||||
"""Get email configuration, preferring database settings over environment variables"""
|
||||
if self.mail_enabled and self.mail_server:
|
||||
return {
|
||||
'MAIL_SERVER': self.mail_server,
|
||||
'MAIL_PORT': self.mail_port or 587,
|
||||
'MAIL_USE_TLS': self.mail_use_tls if self.mail_use_tls is not None else True,
|
||||
'MAIL_USE_SSL': self.mail_use_ssl if self.mail_use_ssl is not None else False,
|
||||
'MAIL_USERNAME': self.mail_username or None,
|
||||
'MAIL_PASSWORD': self.mail_password or None,
|
||||
'MAIL_DEFAULT_SENDER': self.mail_default_sender or 'noreply@timetracker.local',
|
||||
}
|
||||
return None
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert settings to dictionary for API responses"""
|
||||
return {
|
||||
@@ -138,6 +172,14 @@ class Settings(db.Model):
|
||||
'invoice_pdf_template_html': self.invoice_pdf_template_html,
|
||||
'invoice_pdf_template_css': self.invoice_pdf_template_css,
|
||||
'allow_analytics': self.allow_analytics,
|
||||
'mail_enabled': self.mail_enabled,
|
||||
'mail_server': self.mail_server,
|
||||
'mail_port': self.mail_port,
|
||||
'mail_use_tls': self.mail_use_tls,
|
||||
'mail_use_ssl': self.mail_use_ssl,
|
||||
'mail_username': self.mail_username,
|
||||
'mail_password_set': bool(self.mail_password), # Don't expose actual password
|
||||
'mail_default_sender': self.mail_default_sender,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
@@ -1176,3 +1176,174 @@ def delete_api_token(token_id):
|
||||
db.session.rollback()
|
||||
current_app.logger.error(f"Failed to delete API token: {e}")
|
||||
return jsonify({'error': 'Failed to delete token'}), 500
|
||||
|
||||
|
||||
# ==================== Email Configuration Management ====================
|
||||
|
||||
@admin_bp.route('/admin/email')
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def email_support():
|
||||
"""Email configuration and testing page"""
|
||||
from app.utils.email import test_email_configuration
|
||||
|
||||
# Get email configuration status
|
||||
email_status = test_email_configuration()
|
||||
|
||||
# Log dashboard access
|
||||
app_module.log_event("admin.email_support_viewed", user_id=current_user.id)
|
||||
app_module.track_event(current_user.id, "admin.email_support_viewed", {})
|
||||
|
||||
return render_template('admin/email_support.html',
|
||||
email_status=email_status)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email/test', methods=['POST'])
|
||||
@limiter.limit("5 per minute")
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def test_email():
|
||||
"""Send a test email"""
|
||||
from app.utils.email import send_test_email
|
||||
|
||||
data = request.get_json() or {}
|
||||
recipient = data.get('recipient')
|
||||
|
||||
if not recipient:
|
||||
current_app.logger.warning(f"[EMAIL TEST API] No recipient provided by user {current_user.username}")
|
||||
return jsonify({'success': False, 'message': 'Recipient email is required'}), 400
|
||||
|
||||
current_app.logger.info(f"[EMAIL TEST API] Test email request from user {current_user.username} to {recipient}")
|
||||
|
||||
# Send test email
|
||||
sender_name = current_user.username or 'TimeTracker Admin'
|
||||
success, message = send_test_email(recipient, sender_name)
|
||||
|
||||
# Log the test
|
||||
current_app.logger.info(f"[EMAIL TEST API] Result: {'SUCCESS' if success else 'FAILED'} - {message}")
|
||||
app_module.log_event("admin.email_test_sent",
|
||||
user_id=current_user.id,
|
||||
recipient=recipient,
|
||||
success=success)
|
||||
app_module.track_event(current_user.id, "admin.email_test_sent", {
|
||||
'success': success,
|
||||
'configured': success
|
||||
})
|
||||
|
||||
if success:
|
||||
return jsonify({'success': True, 'message': message}), 200
|
||||
else:
|
||||
return jsonify({'success': False, 'message': message}), 500
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email/config-status', methods=['GET'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def email_config_status():
|
||||
"""Get current email configuration status (for AJAX polling)"""
|
||||
from app.utils.email import test_email_configuration
|
||||
|
||||
email_status = test_email_configuration()
|
||||
return jsonify(email_status), 200
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email/configure', methods=['POST'])
|
||||
@limiter.limit("10 per minute")
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def save_email_config():
|
||||
"""Save email configuration to database"""
|
||||
from app.utils.email import reload_mail_config
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
current_app.logger.info(f"[EMAIL CONFIG] Saving email configuration by user {current_user.username}")
|
||||
|
||||
# Get settings
|
||||
settings = Settings.get_settings()
|
||||
|
||||
# Update email configuration
|
||||
settings.mail_enabled = data.get('enabled', False)
|
||||
settings.mail_server = data.get('server', '').strip()
|
||||
settings.mail_port = int(data.get('port', 587))
|
||||
settings.mail_use_tls = data.get('use_tls', True)
|
||||
settings.mail_use_ssl = data.get('use_ssl', False)
|
||||
settings.mail_username = data.get('username', '').strip()
|
||||
|
||||
# Only update password if provided (non-empty)
|
||||
password = data.get('password', '').strip()
|
||||
if password:
|
||||
settings.mail_password = password
|
||||
current_app.logger.info("[EMAIL CONFIG] Password updated")
|
||||
|
||||
settings.mail_default_sender = data.get('default_sender', '').strip()
|
||||
|
||||
current_app.logger.info(f"[EMAIL CONFIG] Settings: enabled={settings.mail_enabled}, "
|
||||
f"server={settings.mail_server}:{settings.mail_port}, "
|
||||
f"tls={settings.mail_use_tls}, ssl={settings.mail_use_ssl}")
|
||||
|
||||
# Validate
|
||||
if settings.mail_enabled and not settings.mail_server:
|
||||
current_app.logger.warning("[EMAIL CONFIG] Validation failed: mail server required")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Mail server is required when email is enabled'
|
||||
}), 400
|
||||
|
||||
if settings.mail_use_tls and settings.mail_use_ssl:
|
||||
current_app.logger.warning("[EMAIL CONFIG] Validation failed: both TLS and SSL enabled")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Cannot use both TLS and SSL. Please choose one.'
|
||||
}), 400
|
||||
|
||||
# Save to database
|
||||
if not safe_commit('admin_save_email_config'):
|
||||
current_app.logger.error("[EMAIL CONFIG] Failed to save to database")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Failed to save email configuration to database'
|
||||
}), 500
|
||||
|
||||
current_app.logger.info("[EMAIL CONFIG] ✓ Configuration saved to database")
|
||||
|
||||
# Reload mail configuration
|
||||
if settings.mail_enabled:
|
||||
current_app.logger.info("[EMAIL CONFIG] Reloading mail configuration...")
|
||||
reload_result = reload_mail_config(current_app._get_current_object())
|
||||
current_app.logger.info(f"[EMAIL CONFIG] Mail config reload: {'SUCCESS' if reload_result else 'FAILED'}")
|
||||
|
||||
# Log the change
|
||||
app_module.log_event("admin.email_config_saved",
|
||||
user_id=current_user.id,
|
||||
enabled=settings.mail_enabled)
|
||||
app_module.track_event(current_user.id, "admin.email_config_saved", {
|
||||
'enabled': settings.mail_enabled,
|
||||
'source': 'database'
|
||||
})
|
||||
|
||||
current_app.logger.info("[EMAIL CONFIG] ✓ Email configuration update complete")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Email configuration saved successfully'
|
||||
}), 200
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email/get-config', methods=['GET'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def get_email_config():
|
||||
"""Get current email configuration from database"""
|
||||
settings = Settings.get_settings()
|
||||
|
||||
return jsonify({
|
||||
'enabled': settings.mail_enabled,
|
||||
'server': settings.mail_server or '',
|
||||
'port': settings.mail_port or 587,
|
||||
'use_tls': settings.mail_use_tls if settings.mail_use_tls is not None else True,
|
||||
'use_ssl': settings.mail_use_ssl if settings.mail_use_ssl is not None else False,
|
||||
'username': settings.mail_username or '',
|
||||
'password_set': bool(settings.mail_password),
|
||||
'default_sender': settings.mail_default_sender or ''
|
||||
}), 200
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
<div>API Tokens</div>
|
||||
<div class="text-xs mt-1 opacity-90">REST API Access</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_support') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
|
||||
<i class="fas fa-envelope mb-2"></i>
|
||||
<div>Email Configuration</div>
|
||||
<div class="text-xs mt-1 opacity-90">Test & Configure</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
|
||||
<i class="fas fa-cog mb-2"></i>
|
||||
<div>Settings</div>
|
||||
|
||||
524
app/templates/admin/email_support.html
Normal file
524
app/templates/admin/email_support.html
Normal file
@@ -0,0 +1,524 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Email Configuration & Testing') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Configure and test email delivery') }}</p>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0">
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back to Admin') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Configuration Form -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Email Configuration') }}</h2>
|
||||
|
||||
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 rounded-lg">
|
||||
<p class="text-sm">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
{{ _('Configure email settings here to save them in the database. Database settings take precedence over environment variables.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="emailConfigForm" class="space-y-4">
|
||||
<!-- Enable Email -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="mailEnabled" class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="mailEnabled" class="ml-2 block text-sm font-semibold text-gray-900 dark:text-gray-300">{{ _('Enable Database Email Configuration') }}</label>
|
||||
</div>
|
||||
|
||||
<div id="emailConfigFields" class="space-y-4 pl-6">
|
||||
<!-- Mail Server -->
|
||||
<div>
|
||||
<label for="mailServer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Mail Server') }} *</label>
|
||||
<input type="text" id="mailServer" class="form-input" placeholder="smtp.gmail.com" required>
|
||||
</div>
|
||||
|
||||
<!-- Mail Port -->
|
||||
<div>
|
||||
<label for="mailPort" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Mail Port') }} *</label>
|
||||
<input type="number" id="mailPort" class="form-input" value="587" required>
|
||||
</div>
|
||||
|
||||
<!-- TLS/SSL -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="mailUseTls" class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" checked>
|
||||
<label for="mailUseTls" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Use TLS') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="mailUseSsl" class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="mailUseSsl" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">{{ _('Use SSL') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="mailUsername" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Username') }}</label>
|
||||
<input type="text" id="mailUsername" class="form-input" placeholder="your-email@gmail.com">
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="mailPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Password') }}
|
||||
<span id="passwordStatus" class="text-sm text-gray-500"></span>
|
||||
</label>
|
||||
<input type="password" id="mailPassword" class="form-input" placeholder="{{ _('Leave empty to keep current') }}">
|
||||
</div>
|
||||
|
||||
<!-- Default Sender -->
|
||||
<div>
|
||||
<label for="mailDefaultSender" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Default Sender') }} *</label>
|
||||
<input type="email" id="mailDefaultSender" class="form-input" placeholder="noreply@yourdomain.com" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="submit" class="btn btn-primary" id="saveConfigBtn">
|
||||
<i class="fas fa-save mr-2"></i>{{ _('Save Configuration') }}
|
||||
</button>
|
||||
<button type="button" onclick="loadConfig()" class="btn btn-secondary">
|
||||
<i class="fas fa-undo mr-2"></i>{{ _('Reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Save Result Message -->
|
||||
<div id="saveResult" class="mt-4 hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Status Card -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold">{{ _('Email Configuration Status') }}</h2>
|
||||
<button onclick="refreshStatus()" class="btn btn-sm btn-secondary" id="refreshBtn">
|
||||
<i class="fas fa-sync-alt"></i> {{ _('Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="statusContainer">
|
||||
{% if email_status.configured %}
|
||||
<div class="bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-200 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _('Email is configured!') }}</strong>
|
||||
<span class="block sm:inline">{{ _('Your email settings are properly set up.') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-triangle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _('Email is not configured') }}</strong>
|
||||
<span class="block sm:inline">{{ _('Please configure email settings in your environment variables.') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Configuration Errors -->
|
||||
{% if email_status.errors %}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-red-800 dark:text-red-300 font-semibold mb-2">
|
||||
<i class="fas fa-times-circle mr-2"></i>{{ _('Configuration Errors') }}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-red-700 dark:text-red-300">
|
||||
{% for error in email_status.errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Configuration Warnings -->
|
||||
{% if email_status.warnings %}
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-300 dark:border-yellow-700 rounded-lg p-4 mb-4">
|
||||
<h3 class="text-yellow-800 dark:text-yellow-300 font-semibold mb-2">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ _('Configuration Warnings') }}
|
||||
</h3>
|
||||
<ul class="list-disc list-inside text-yellow-700 dark:text-yellow-300">
|
||||
{% for warning in email_status.warnings %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Current Settings -->
|
||||
<div class="bg-bg-light dark:bg-bg-dark border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3">{{ _('Current Email Settings') }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Mail Server') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.server }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Port') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.port }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Username') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.username }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Password Set') }}:</span>
|
||||
<span class="font-mono">
|
||||
{% if email_status.settings.password_set %}
|
||||
<i class="fas fa-check text-green-600"></i> {{ _('Yes') }}
|
||||
{% else %}
|
||||
<i class="fas fa-times text-red-600"></i> {{ _('No') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Use TLS') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.use_tls }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Use SSL') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.use_ssl }}</span>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Default Sender') }}:</span>
|
||||
<span class="font-mono">{{ email_status.settings.default_sender }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Email Card -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Send Test Email') }}</h2>
|
||||
|
||||
<div id="testEmailForm">
|
||||
<div class="mb-4">
|
||||
<label for="recipientEmail" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Recipient Email Address') }}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="recipientEmail"
|
||||
class="form-input md:w-1/2"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<button onclick="sendTestEmail()" class="btn btn-primary" id="sendTestBtn">
|
||||
<i class="fas fa-paper-plane mr-2"></i>{{ _('Send Test Email') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test Result Message -->
|
||||
<div id="testResult" class="mt-4 hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Guide Card -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Configuration Guide') }}</h2>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="mb-4">{{ _('To configure email, set the following environment variables:') }}</p>
|
||||
|
||||
<div class="bg-bg-light dark:bg-bg-dark border border-border-light dark:border-border-dark rounded-lg p-4 mb-4 font-mono text-sm overflow-x-auto">
|
||||
<div># {{ _('Basic SMTP Settings') }}</div>
|
||||
<div>MAIL_SERVER=smtp.gmail.com</div>
|
||||
<div>MAIL_PORT=587</div>
|
||||
<div>MAIL_USE_TLS=true</div>
|
||||
<div>MAIL_USE_SSL=false</div>
|
||||
<div><br></div>
|
||||
<div># {{ _('Authentication') }}</div>
|
||||
<div>MAIL_USERNAME=your-email@gmail.com</div>
|
||||
<div>MAIL_PASSWORD=your-app-password</div>
|
||||
<div><br></div>
|
||||
<div># {{ _('Sender Information') }}</div>
|
||||
<div>MAIL_DEFAULT_SENDER=noreply@yourdomain.com</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold mb-2">{{ _('Common SMTP Providers') }}</h3>
|
||||
<ul class="list-disc list-inside space-y-2 mb-4">
|
||||
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS) - {{ _('Requires app password') }}</li>
|
||||
<li><strong>Outlook/Office365:</strong> smtp.office365.com:587 (TLS)</li>
|
||||
<li><strong>SendGrid:</strong> smtp.sendgrid.net:587 (TLS)</li>
|
||||
<li><strong>Amazon SES:</strong> email-smtp.[region].amazonaws.com:587 (TLS)</li>
|
||||
<li><strong>Mailgun:</strong> smtp.mailgun.org:587 (TLS)</li>
|
||||
</ul>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 rounded-lg p-4">
|
||||
<h4 class="font-semibold mb-2">
|
||||
<i class="fas fa-info-circle mr-2"></i>{{ _('Important Notes') }}
|
||||
</h4>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li>{{ _('Gmail requires an App Password if 2FA is enabled') }}</li>
|
||||
<li>{{ _('Restart the application after changing email settings') }}</li>
|
||||
<li>{{ _('Check firewall rules if emails are not sending') }}</li>
|
||||
<li>{{ _('For production, use a dedicated email service like SendGrid or Amazon SES') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load configuration on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfig();
|
||||
toggleConfigFields();
|
||||
|
||||
// Add event listener for enable checkbox
|
||||
document.getElementById('mailEnabled').addEventListener('change', toggleConfigFields);
|
||||
});
|
||||
|
||||
// Toggle config fields based on enabled checkbox
|
||||
function toggleConfigFields() {
|
||||
const enabled = document.getElementById('mailEnabled').checked;
|
||||
const fields = document.getElementById('emailConfigFields');
|
||||
if (enabled) {
|
||||
fields.classList.remove('opacity-50');
|
||||
fields.querySelectorAll('input').forEach(input => input.disabled = false);
|
||||
} else {
|
||||
fields.classList.add('opacity-50');
|
||||
fields.querySelectorAll('input').forEach(input => input.disabled = true);
|
||||
}
|
||||
}
|
||||
|
||||
// Load configuration from database
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('{{ url_for("admin.get_email_config") }}');
|
||||
const config = await response.json();
|
||||
|
||||
// Populate form
|
||||
document.getElementById('mailEnabled').checked = config.enabled;
|
||||
document.getElementById('mailServer').value = config.server || '';
|
||||
document.getElementById('mailPort').value = config.port || 587;
|
||||
document.getElementById('mailUseTls').checked = config.use_tls;
|
||||
document.getElementById('mailUseSsl').checked = config.use_ssl;
|
||||
document.getElementById('mailUsername').value = config.username || '';
|
||||
document.getElementById('mailDefaultSender').value = config.default_sender || '';
|
||||
|
||||
// Show password status
|
||||
const passwordStatus = document.getElementById('passwordStatus');
|
||||
if (config.password_set) {
|
||||
passwordStatus.textContent = '({{ _("password is set") }})';
|
||||
passwordStatus.className = 'text-sm text-green-600';
|
||||
} else {
|
||||
passwordStatus.textContent = '({{ _("no password set") }})';
|
||||
passwordStatus.className = 'text-sm text-gray-500';
|
||||
}
|
||||
|
||||
toggleConfigFields();
|
||||
} catch (error) {
|
||||
console.error('Failed to load configuration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration to database
|
||||
document.getElementById('emailConfigForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const saveBtn = document.getElementById('saveConfigBtn');
|
||||
const resultDiv = document.getElementById('saveResult');
|
||||
|
||||
// Collect form data
|
||||
const config = {
|
||||
enabled: document.getElementById('mailEnabled').checked,
|
||||
server: document.getElementById('mailServer').value.trim(),
|
||||
port: parseInt(document.getElementById('mailPort').value),
|
||||
use_tls: document.getElementById('mailUseTls').checked,
|
||||
use_ssl: document.getElementById('mailUseSsl').checked,
|
||||
username: document.getElementById('mailUsername').value.trim(),
|
||||
password: document.getElementById('mailPassword').value,
|
||||
default_sender: document.getElementById('mailDefaultSender').value.trim()
|
||||
};
|
||||
|
||||
// Disable button and show loading
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{{ _("Saving...") }}';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("admin.save_email_config") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSaveResult('success', data.message);
|
||||
// Clear password field after successful save
|
||||
document.getElementById('mailPassword').value = '';
|
||||
// Reload config to update password status
|
||||
loadConfig();
|
||||
// Refresh status and reload page after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
showSaveResult('error', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save configuration:', error);
|
||||
showSaveResult('error', '{{ _("Failed to save configuration. Please try again.") }}');
|
||||
} finally {
|
||||
// Re-enable button
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fas fa-save mr-2"></i>{{ _("Save Configuration") }}';
|
||||
}
|
||||
});
|
||||
|
||||
// Show save result message
|
||||
function showSaveResult(type, message) {
|
||||
const resultDiv = document.getElementById('saveResult');
|
||||
|
||||
if (type === 'success') {
|
||||
resultDiv.className = 'mt-4 bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-200 px-4 py-3 rounded relative';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _("Success!") }}</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.className = 'mt-4 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _("Error") }}</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Refresh configuration status
|
||||
async function refreshStatus() {
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const icon = refreshBtn.querySelector('i');
|
||||
|
||||
// Add spinning animation
|
||||
icon.classList.add('fa-spin');
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("admin.email_config_status") }}');
|
||||
const data = await response.json();
|
||||
|
||||
// Reload the page to show updated status
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh status:', error);
|
||||
alert('{{ _("Failed to refresh status. Please try again.") }}');
|
||||
} finally {
|
||||
icon.classList.remove('fa-spin');
|
||||
refreshBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Send test email
|
||||
async function sendTestEmail() {
|
||||
const recipientEmail = document.getElementById('recipientEmail').value.trim();
|
||||
const sendBtn = document.getElementById('sendTestBtn');
|
||||
const resultDiv = document.getElementById('testResult');
|
||||
|
||||
// Validate email
|
||||
if (!recipientEmail || !recipientEmail.includes('@')) {
|
||||
showResult('error', '{{ _("Please enter a valid email address") }}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button and show loading
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>{{ _("Sending...") }}';
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('{{ url_for("admin.test_email") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ recipient: recipientEmail })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showResult('success', data.message);
|
||||
} else {
|
||||
showResult('error', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send test email:', error);
|
||||
showResult('error', '{{ _("Failed to send test email. Please check your configuration.") }}');
|
||||
} finally {
|
||||
// Re-enable button
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>{{ _("Send Test Email") }}';
|
||||
}
|
||||
}
|
||||
|
||||
// Show result message
|
||||
function showResult(type, message) {
|
||||
const resultDiv = document.getElementById('testResult');
|
||||
|
||||
if (type === 'success') {
|
||||
resultDiv.className = 'mt-4 bg-green-100 dark:bg-green-900 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-200 px-4 py-3 rounded relative';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-check-circle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _("Success!") }}</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.className = 'mt-4 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded relative';
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-exclamation-circle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _("Error") }}</strong>
|
||||
<span class="block sm:inline">${message}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Allow sending with Enter key
|
||||
document.getElementById('recipientEmail').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendTestEmail();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
154
app/templates/email/test_email.html
Normal file
154
app/templates/email/test_email.html
Normal file
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeTracker Email Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 3px solid #3b82f6;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #3b82f6;
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
.header p {
|
||||
color: #666;
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.success-badge {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.info-section {
|
||||
background-color: #f9fafb;
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.info-section h2 {
|
||||
margin-top: 0;
|
||||
color: #3b82f6;
|
||||
font-size: 18px;
|
||||
}
|
||||
.info-item {
|
||||
margin: 8px 0;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: bold;
|
||||
color: #4b5563;
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
}
|
||||
.info-value {
|
||||
color: #1f2937;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
.checkmark {
|
||||
font-size: 48px;
|
||||
color: #10b981;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>TimeTracker</h1>
|
||||
<p>Email Configuration Test</p>
|
||||
</div>
|
||||
|
||||
<div class="checkmark">✓</div>
|
||||
|
||||
<div class="success-badge">
|
||||
Email Configuration Working!
|
||||
</div>
|
||||
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>This is a test email from <strong>TimeTracker</strong> to verify that your email configuration is working correctly.</p>
|
||||
|
||||
<p>If you received this email, congratulations! Your email settings are properly configured and emails are being delivered successfully.</p>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>Test Details</h2>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Sent at:</span>
|
||||
<span class="info-value">{{ datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') }} UTC</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Sent by:</span>
|
||||
<span class="info-value">{{ sender_name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Mail Server:</span>
|
||||
<span class="info-value">{{ mail_server }}:{{ mail_port }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">TLS Enabled:</span>
|
||||
<span class="info-value">{{ 'Yes' if use_tls else 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">SSL Enabled:</span>
|
||||
<span class="info-value">{{ 'Yes' if use_ssl else 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You can now use email features in TimeTracker, including:</p>
|
||||
<ul>
|
||||
<li>Invoice notifications</li>
|
||||
<li>Task assignment notifications</li>
|
||||
<li>Weekly time summaries</li>
|
||||
<li>Comment mentions</li>
|
||||
<li>System alerts</li>
|
||||
</ul>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>TimeTracker</strong> - Time Tracking & Project Management</p>
|
||||
<p>This is an automated test email. Please do not reply to this message.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,8 +11,11 @@ mail = Mail()
|
||||
|
||||
|
||||
def init_mail(app):
|
||||
"""Initialize Flask-Mail with the app"""
|
||||
# Configure mail settings from environment variables
|
||||
"""Initialize Flask-Mail with the app
|
||||
|
||||
Checks for database settings first, then falls back to environment variables.
|
||||
"""
|
||||
# First, load defaults from environment variables
|
||||
app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'localhost')
|
||||
app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587))
|
||||
app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true'
|
||||
@@ -22,10 +25,51 @@ def init_mail(app):
|
||||
app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local')
|
||||
app.config['MAIL_MAX_EMAILS'] = int(os.getenv('MAIL_MAX_EMAILS', 100))
|
||||
|
||||
# Check if database settings should override environment variables
|
||||
try:
|
||||
from app.models import Settings
|
||||
from app import db
|
||||
|
||||
if db.session.is_active:
|
||||
settings = Settings.get_settings()
|
||||
db_config = settings.get_mail_config()
|
||||
|
||||
if db_config:
|
||||
# Database settings take precedence
|
||||
app.config.update(db_config)
|
||||
app.logger.info("Using database email configuration")
|
||||
else:
|
||||
app.logger.info("Using environment variable email configuration")
|
||||
except Exception as e:
|
||||
# If database is not available, fall back to environment variables
|
||||
app.logger.debug(f"Could not load email settings from database: {e}")
|
||||
|
||||
mail.init_app(app)
|
||||
return mail
|
||||
|
||||
|
||||
def reload_mail_config(app):
|
||||
"""Reload email configuration from database
|
||||
|
||||
Call this after updating email settings in the database to apply changes.
|
||||
"""
|
||||
try:
|
||||
from app.models import Settings
|
||||
settings = Settings.get_settings()
|
||||
db_config = settings.get_mail_config()
|
||||
|
||||
if db_config:
|
||||
# Update app configuration
|
||||
app.config.update(db_config)
|
||||
# Reinitialize mail with new config
|
||||
mail.init_app(app)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
app.logger.error(f"Failed to reload email configuration: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_async_email(app, msg):
|
||||
"""Send email asynchronously in background thread"""
|
||||
with app.app_context():
|
||||
@@ -250,3 +294,171 @@ TimeTracker - Time Tracking & Project Management
|
||||
|
||||
send_email(subject, user.email, text_body, html_body)
|
||||
|
||||
|
||||
def test_email_configuration():
|
||||
"""Test email configuration and return status
|
||||
|
||||
Returns:
|
||||
dict: Status information with 'configured', 'settings', 'errors', 'source' keys
|
||||
"""
|
||||
status = {
|
||||
'configured': False,
|
||||
'settings': {},
|
||||
'errors': [],
|
||||
'warnings': [],
|
||||
'source': 'environment' # or 'database'
|
||||
}
|
||||
|
||||
# Check if database configuration is enabled
|
||||
try:
|
||||
from app.models import Settings
|
||||
settings = Settings.get_settings()
|
||||
if settings.mail_enabled and settings.mail_server:
|
||||
status['source'] = 'database'
|
||||
mail_server = settings.mail_server
|
||||
mail_port = settings.mail_port
|
||||
mail_username = settings.mail_username
|
||||
mail_password = settings.mail_password
|
||||
mail_use_tls = settings.mail_use_tls
|
||||
mail_use_ssl = settings.mail_use_ssl
|
||||
mail_default_sender = settings.mail_default_sender
|
||||
else:
|
||||
# Use environment/app config
|
||||
mail_server = current_app.config.get('MAIL_SERVER')
|
||||
mail_port = current_app.config.get('MAIL_PORT')
|
||||
mail_username = current_app.config.get('MAIL_USERNAME')
|
||||
mail_password = current_app.config.get('MAIL_PASSWORD')
|
||||
mail_use_tls = current_app.config.get('MAIL_USE_TLS')
|
||||
mail_use_ssl = current_app.config.get('MAIL_USE_SSL')
|
||||
mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER')
|
||||
except Exception:
|
||||
# Fall back to app config if database not available
|
||||
mail_server = current_app.config.get('MAIL_SERVER')
|
||||
mail_port = current_app.config.get('MAIL_PORT')
|
||||
mail_username = current_app.config.get('MAIL_USERNAME')
|
||||
mail_password = current_app.config.get('MAIL_PASSWORD')
|
||||
mail_use_tls = current_app.config.get('MAIL_USE_TLS')
|
||||
mail_use_ssl = current_app.config.get('MAIL_USE_SSL')
|
||||
mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER')
|
||||
|
||||
status['settings'] = {
|
||||
'server': mail_server or 'Not configured',
|
||||
'port': mail_port or 'Not configured',
|
||||
'username': mail_username or 'Not configured',
|
||||
'password_set': bool(mail_password),
|
||||
'use_tls': mail_use_tls,
|
||||
'use_ssl': mail_use_ssl,
|
||||
'default_sender': mail_default_sender or 'Not configured'
|
||||
}
|
||||
|
||||
# Check for configuration issues
|
||||
if not mail_server or mail_server == 'localhost':
|
||||
status['errors'].append('Mail server not configured or set to localhost')
|
||||
|
||||
if not mail_default_sender or mail_default_sender == 'noreply@timetracker.local':
|
||||
status['warnings'].append('Default sender email should be configured with a real email address')
|
||||
|
||||
if mail_use_tls and mail_use_ssl:
|
||||
status['errors'].append('Cannot use both TLS and SSL. Choose one.')
|
||||
|
||||
if not mail_username and mail_server not in ['localhost', '127.0.0.1']:
|
||||
status['warnings'].append('MAIL_USERNAME not set (may be required for authentication)')
|
||||
|
||||
if not mail_password and mail_username:
|
||||
status['warnings'].append('MAIL_PASSWORD not set but MAIL_USERNAME is configured')
|
||||
|
||||
# Mark as configured if minimum requirements are met
|
||||
status['configured'] = bool(mail_server and mail_server != 'localhost' and not status['errors'])
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def send_test_email(recipient_email, sender_name='TimeTracker Admin'):
|
||||
"""Send a test email to verify email configuration
|
||||
|
||||
Args:
|
||||
recipient_email: Email address to send test email to
|
||||
sender_name: Name of the sender
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
current_app.logger.info(f"[EMAIL TEST] Starting test email send to: {recipient_email}")
|
||||
|
||||
# Validate recipient email
|
||||
if not recipient_email or '@' not in recipient_email:
|
||||
current_app.logger.warning(f"[EMAIL TEST] Invalid recipient email: {recipient_email}")
|
||||
return False, 'Invalid recipient email address'
|
||||
|
||||
# Check if mail is configured
|
||||
mail_server = current_app.config.get('MAIL_SERVER')
|
||||
if not mail_server:
|
||||
current_app.logger.error("[EMAIL TEST] Mail server not configured")
|
||||
return False, 'Mail server not configured. Please set MAIL_SERVER in environment variables.'
|
||||
|
||||
# Log current configuration
|
||||
current_app.logger.info(f"[EMAIL TEST] Configuration:")
|
||||
current_app.logger.info(f" - Server: {mail_server}:{current_app.config.get('MAIL_PORT')}")
|
||||
current_app.logger.info(f" - TLS: {current_app.config.get('MAIL_USE_TLS')}")
|
||||
current_app.logger.info(f" - SSL: {current_app.config.get('MAIL_USE_SSL')}")
|
||||
current_app.logger.info(f" - Username: {current_app.config.get('MAIL_USERNAME')}")
|
||||
current_app.logger.info(f" - Sender: {current_app.config.get('MAIL_DEFAULT_SENDER')}")
|
||||
|
||||
subject = 'TimeTracker Email Test'
|
||||
|
||||
text_body = f"""
|
||||
Hello,
|
||||
|
||||
This is a test email from TimeTracker to verify your email configuration is working correctly.
|
||||
|
||||
If you received this email, your email settings are properly configured!
|
||||
|
||||
Test Details:
|
||||
- Sent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC
|
||||
- Sent by: {sender_name}
|
||||
- Mail Server: {current_app.config.get('MAIL_SERVER')}:{current_app.config.get('MAIL_PORT')}
|
||||
- TLS Enabled: {current_app.config.get('MAIL_USE_TLS')}
|
||||
- SSL Enabled: {current_app.config.get('MAIL_USE_SSL')}
|
||||
|
||||
---
|
||||
TimeTracker - Time Tracking & Project Management
|
||||
"""
|
||||
|
||||
try:
|
||||
html_body = render_template(
|
||||
'email/test_email.html',
|
||||
sender_name=sender_name,
|
||||
mail_server=current_app.config.get('MAIL_SERVER'),
|
||||
mail_port=current_app.config.get('MAIL_PORT'),
|
||||
use_tls=current_app.config.get('MAIL_USE_TLS'),
|
||||
use_ssl=current_app.config.get('MAIL_USE_SSL')
|
||||
)
|
||||
current_app.logger.info("[EMAIL TEST] HTML template rendered successfully")
|
||||
except Exception as template_error:
|
||||
# If template doesn't exist, use text only
|
||||
current_app.logger.warning(f"[EMAIL TEST] HTML template not available: {template_error}")
|
||||
html_body = None
|
||||
|
||||
# Create message
|
||||
current_app.logger.info("[EMAIL TEST] Creating email message")
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[recipient_email],
|
||||
body=text_body,
|
||||
html=html_body,
|
||||
sender=current_app.config['MAIL_DEFAULT_SENDER']
|
||||
)
|
||||
|
||||
# Send synchronously for testing (so we can catch errors)
|
||||
current_app.logger.info("[EMAIL TEST] Attempting to send email via SMTP...")
|
||||
mail.send(msg)
|
||||
current_app.logger.info(f"[EMAIL TEST] ✓ Email sent successfully to {recipient_email}")
|
||||
|
||||
return True, f'Test email sent successfully to {recipient_email}'
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[EMAIL TEST] ✗ Failed to send test email: {type(e).__name__}: {str(e)}")
|
||||
current_app.logger.exception("[EMAIL TEST] Full exception trace:")
|
||||
return False, f'Failed to send test email: {str(e)}'
|
||||
|
||||
|
||||
426
docs/EMAIL_CONFIGURATION.md
Normal file
426
docs/EMAIL_CONFIGURATION.md
Normal file
@@ -0,0 +1,426 @@
|
||||
# Email Configuration Guide
|
||||
|
||||
This guide explains how to configure and use the email functionality in TimeTracker.
|
||||
|
||||
## Overview
|
||||
|
||||
TimeTracker includes built-in email support for:
|
||||
- Test emails to verify configuration
|
||||
- Invoice notifications
|
||||
- Task assignment notifications
|
||||
- Weekly time summaries
|
||||
- Comment mentions
|
||||
- System alerts
|
||||
|
||||
## Configuration Methods
|
||||
|
||||
TimeTracker supports two ways to configure email:
|
||||
|
||||
### 1. **Database Configuration** (Recommended)
|
||||
Configure email settings through the admin web interface. Settings are saved to the database and persist between sessions.
|
||||
|
||||
**Advantages:**
|
||||
- No server restart required for changes
|
||||
- Easy to update via web interface
|
||||
- Settings persist in database
|
||||
- Can be changed by admins without server access
|
||||
|
||||
**To Use:**
|
||||
1. Navigate to Admin → Email Configuration
|
||||
2. Check "Enable Database Email Configuration"
|
||||
3. Fill in the form and save
|
||||
|
||||
### 2. **Environment Variables** (Fallback)
|
||||
Configure email settings through environment variables. These serve as defaults when database configuration is disabled.
|
||||
|
||||
**Advantages:**
|
||||
- More secure for sensitive credentials
|
||||
- Standard configuration method
|
||||
- Works without database access
|
||||
|
||||
**To Use:**
|
||||
Add settings to your `.env` file or environment
|
||||
|
||||
---
|
||||
|
||||
## Configuration Hierarchy
|
||||
|
||||
Email settings are loaded in this order (highest priority first):
|
||||
|
||||
1. **Database Settings** (when `mail_enabled` is `True` in settings table)
|
||||
2. **Environment Variables** (fallback)
|
||||
3. **Default Values**
|
||||
|
||||
## Database Configuration
|
||||
|
||||
Email settings are configured through environment variables. Add these to your `.env` file or set them in your environment:
|
||||
|
||||
### Basic SMTP Configuration
|
||||
|
||||
```bash
|
||||
# SMTP Server Settings
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USE_SSL=false
|
||||
|
||||
# Authentication
|
||||
MAIL_USERNAME=your-email@gmail.com
|
||||
MAIL_PASSWORD=your-app-password
|
||||
|
||||
# Sender Information
|
||||
MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
|
||||
# Optional: Maximum emails per connection
|
||||
MAIL_MAX_EMAILS=100
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Description | Default | Required |
|
||||
|----------|-------------|---------|----------|
|
||||
| `MAIL_SERVER` | SMTP server hostname | `localhost` | Yes |
|
||||
| `MAIL_PORT` | SMTP server port | `587` | Yes |
|
||||
| `MAIL_USE_TLS` | Use TLS encryption | `true` | No |
|
||||
| `MAIL_USE_SSL` | Use SSL encryption | `false` | No |
|
||||
| `MAIL_USERNAME` | SMTP username for authentication | None | Yes (for most providers) |
|
||||
| `MAIL_PASSWORD` | SMTP password | None | Yes (for most providers) |
|
||||
| `MAIL_DEFAULT_SENDER` | Default "From" address | `noreply@timetracker.local` | Yes |
|
||||
| `MAIL_MAX_EMAILS` | Max emails per SMTP connection | `100` | No |
|
||||
|
||||
## Common Email Providers
|
||||
|
||||
### Gmail
|
||||
|
||||
```bash
|
||||
MAIL_SERVER=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=your-email@gmail.com
|
||||
MAIL_PASSWORD=your-app-password
|
||||
MAIL_DEFAULT_SENDER=your-email@gmail.com
|
||||
```
|
||||
|
||||
**Important:** Gmail requires an [App Password](https://support.google.com/accounts/answer/185833) when 2-factor authentication is enabled.
|
||||
|
||||
### Outlook / Office 365
|
||||
|
||||
```bash
|
||||
MAIL_SERVER=smtp.office365.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=your-email@outlook.com
|
||||
MAIL_PASSWORD=your-password
|
||||
MAIL_DEFAULT_SENDER=your-email@outlook.com
|
||||
```
|
||||
|
||||
### SendGrid
|
||||
|
||||
```bash
|
||||
MAIL_SERVER=smtp.sendgrid.net
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=apikey
|
||||
MAIL_PASSWORD=your-sendgrid-api-key
|
||||
MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
### Amazon SES
|
||||
|
||||
```bash
|
||||
MAIL_SERVER=email-smtp.us-east-1.amazonaws.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=your-smtp-username
|
||||
MAIL_PASSWORD=your-smtp-password
|
||||
MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
Replace `us-east-1` with your AWS region.
|
||||
|
||||
### Mailgun
|
||||
|
||||
```bash
|
||||
MAIL_SERVER=smtp.mailgun.org
|
||||
MAIL_PORT=587
|
||||
MAIL_USE_TLS=true
|
||||
MAIL_USERNAME=postmaster@yourdomain.mailgun.org
|
||||
MAIL_PASSWORD=your-mailgun-password
|
||||
MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
## Testing Email Configuration
|
||||
|
||||
### Using the Admin Panel (Database Configuration)
|
||||
|
||||
1. Log in as an administrator
|
||||
2. Navigate to **Admin** → **Email Configuration**
|
||||
3. **Configure Settings**:
|
||||
- Check "Enable Database Email Configuration"
|
||||
- Fill in: Mail Server, Port, Username, Password, etc.
|
||||
- Click "Save Configuration"
|
||||
4. **Test Configuration**:
|
||||
- Review the configuration status (should show "configured")
|
||||
- Enter your email address in the test form
|
||||
- Click "Send Test Email"
|
||||
- Check your inbox for the test email
|
||||
|
||||
### Using Environment Variables
|
||||
|
||||
1. Set environment variables in `.env`
|
||||
2. Restart the application
|
||||
3. Navigate to **Admin** → **Email Configuration**
|
||||
4. Review the configuration status
|
||||
5. Send a test email
|
||||
|
||||
### Using the Command Line
|
||||
|
||||
You can also test email configuration programmatically:
|
||||
|
||||
```python
|
||||
from app import create_app
|
||||
from app.utils.email import send_test_email
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
success, message = send_test_email('your-email@example.com', 'Test')
|
||||
print(f"Success: {success}")
|
||||
print(f"Message: {message}")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Email Not Sending
|
||||
|
||||
1. **Check Configuration Status**
|
||||
- Go to Admin → Email Configuration
|
||||
- Review any errors or warnings displayed
|
||||
|
||||
2. **Verify Credentials**
|
||||
- Ensure username and password are correct
|
||||
- For Gmail, use an App Password, not your regular password
|
||||
|
||||
3. **Check Firewall Rules**
|
||||
- Ensure outbound connections to SMTP port are allowed
|
||||
- Test connectivity: `telnet smtp.gmail.com 587`
|
||||
|
||||
4. **Review Logs**
|
||||
- Check application logs for email-related errors
|
||||
- Look for SMTP authentication or connection errors
|
||||
|
||||
5. **TLS/SSL Configuration**
|
||||
- Don't enable both `MAIL_USE_TLS` and `MAIL_USE_SSL`
|
||||
- Use TLS (port 587) for most modern SMTP servers
|
||||
- Use SSL (port 465) only if required by your provider
|
||||
|
||||
### Common Error Messages
|
||||
|
||||
#### "Mail server not configured"
|
||||
- Set `MAIL_SERVER` environment variable
|
||||
- Ensure it's not set to `localhost`
|
||||
|
||||
#### "Authentication failed"
|
||||
- Verify `MAIL_USERNAME` and `MAIL_PASSWORD`
|
||||
- For Gmail, generate an App Password
|
||||
- Check if your account requires 2FA
|
||||
|
||||
#### "Connection refused"
|
||||
- Check firewall rules
|
||||
- Verify SMTP port is correct (587 for TLS, 465 for SSL, 25 for unencrypted)
|
||||
- Ensure server can reach SMTP host
|
||||
|
||||
#### "TLS/SSL handshake failed"
|
||||
- Check `MAIL_USE_TLS` and `MAIL_USE_SSL` settings
|
||||
- Ensure only one is enabled
|
||||
- Verify port matches TLS/SSL setting
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Use App Passwords**
|
||||
- Never use your main account password
|
||||
- Generate app-specific passwords for Gmail, Outlook, etc.
|
||||
|
||||
2. **Use Environment Variables**
|
||||
- Never commit email credentials to version control
|
||||
- Use `.env` file (excluded from git)
|
||||
- Use secrets management in production
|
||||
|
||||
3. **Use Dedicated Email Service**
|
||||
- For production, use SendGrid, Amazon SES, or similar
|
||||
- These provide better deliverability and monitoring
|
||||
- Personal email accounts may have sending limits
|
||||
|
||||
4. **Configure SPF/DKIM/DMARC**
|
||||
- Set up proper DNS records for your sending domain
|
||||
- Improves email deliverability
|
||||
- Reduces likelihood of emails being marked as spam
|
||||
|
||||
5. **Limit Default Sender**
|
||||
- Use a proper noreply address
|
||||
- Don't use personal email as default sender
|
||||
|
||||
## Email Templates
|
||||
|
||||
Email templates are located in `app/templates/email/`. Available templates:
|
||||
|
||||
- `test_email.html` - Test email template
|
||||
- `overdue_invoice.html` - Overdue invoice notification
|
||||
- `task_assigned.html` - Task assignment notification
|
||||
- `weekly_summary.html` - Weekly time summary
|
||||
- `comment_mention.html` - Comment mention notification
|
||||
|
||||
### Customizing Templates
|
||||
|
||||
To customize email templates:
|
||||
|
||||
1. Navigate to `app/templates/email/`
|
||||
2. Edit the HTML template files
|
||||
3. Use Jinja2 syntax for dynamic content
|
||||
4. Test your changes using the admin panel
|
||||
|
||||
Example:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello {{ user.display_name }}!</h1>
|
||||
<p>{{ message }}</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `send_email(subject, recipients, text_body, html_body=None, sender=None, attachments=None)`
|
||||
|
||||
Send an email message.
|
||||
|
||||
**Parameters:**
|
||||
- `subject` (str): Email subject line
|
||||
- `recipients` (list): List of recipient email addresses
|
||||
- `text_body` (str): Plain text email body
|
||||
- `html_body` (str, optional): HTML email body
|
||||
- `sender` (str, optional): Sender email address (defaults to `MAIL_DEFAULT_SENDER`)
|
||||
- `attachments` (list, optional): List of (filename, content_type, data) tuples
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
from app.utils.email import send_email
|
||||
|
||||
send_email(
|
||||
subject='Welcome to TimeTracker',
|
||||
recipients=['user@example.com'],
|
||||
text_body='Welcome to our application!',
|
||||
html_body='<p>Welcome to our application!</p>'
|
||||
)
|
||||
```
|
||||
|
||||
### `test_email_configuration()`
|
||||
|
||||
Test email configuration and return status.
|
||||
|
||||
**Returns:**
|
||||
- `dict`: Configuration status with keys:
|
||||
- `configured` (bool): Whether email is properly configured
|
||||
- `settings` (dict): Current email settings
|
||||
- `errors` (list): Configuration errors
|
||||
- `warnings` (list): Configuration warnings
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
from app.utils.email import test_email_configuration
|
||||
|
||||
status = test_email_configuration()
|
||||
if status['configured']:
|
||||
print("Email is configured!")
|
||||
else:
|
||||
print("Errors:", status['errors'])
|
||||
```
|
||||
|
||||
### `send_test_email(recipient_email, sender_name='TimeTracker Admin')`
|
||||
|
||||
Send a test email to verify configuration.
|
||||
|
||||
**Parameters:**
|
||||
- `recipient_email` (str): Email address to send test to
|
||||
- `sender_name` (str, optional): Name of sender
|
||||
|
||||
**Returns:**
|
||||
- `tuple`: (success: bool, message: str)
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
from app.utils.email import send_test_email
|
||||
|
||||
success, message = send_test_email('test@example.com')
|
||||
if success:
|
||||
print("Test email sent!")
|
||||
else:
|
||||
print("Error:", message)
|
||||
```
|
||||
|
||||
## Docker Configuration
|
||||
|
||||
### Option 1: Database Configuration (Recommended)
|
||||
|
||||
1. Start TimeTracker with Docker
|
||||
2. Log in as administrator
|
||||
3. Navigate to Admin → Email Configuration
|
||||
4. Configure email through the web interface
|
||||
5. No restart needed!
|
||||
|
||||
### Option 2: Environment Variables
|
||||
|
||||
When running TimeTracker in Docker, add email configuration to your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
- MAIL_SERVER=smtp.gmail.com
|
||||
- MAIL_PORT=587
|
||||
- MAIL_USE_TLS=true
|
||||
- MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- MAIL_DEFAULT_SENDER=${MAIL_DEFAULT_SENDER}
|
||||
```
|
||||
|
||||
Then set the values in your `.env` file:
|
||||
|
||||
```bash
|
||||
MAIL_USERNAME=your-email@gmail.com
|
||||
MAIL_PASSWORD=your-app-password
|
||||
MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Note:** Database configuration (Option 1) takes precedence when enabled.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
The email test endpoint is rate-limited to 5 requests per minute to prevent abuse.
|
||||
|
||||
### Asynchronous Sending
|
||||
|
||||
Emails are sent asynchronously in background threads to avoid blocking the main application. This is handled automatically.
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
Flask-Mail manages SMTP connection pooling automatically based on `MAIL_MAX_EMAILS` setting.
|
||||
|
||||
## Support
|
||||
|
||||
For issues with email configuration:
|
||||
|
||||
1. Check the [GitHub Issues](https://github.com/yourusername/timetracker/issues)
|
||||
2. Review application logs
|
||||
3. Test with a simple SMTP client to verify credentials
|
||||
4. Check your email provider's documentation
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Admin Panel Guide](ADMIN_GUIDE.md)
|
||||
- [Configuration Guide](CONFIGURATION.md)
|
||||
- [Deployment Guide](../DEPLOYMENT_GUIDE.md)
|
||||
|
||||
11
env.example
11
env.example
@@ -54,6 +54,17 @@ AUTH_METHOD=local
|
||||
BACKUP_RETENTION_DAYS=30
|
||||
BACKUP_TIME=02:00
|
||||
|
||||
# Email settings (Flask-Mail)
|
||||
# Configure these to enable email notifications and features
|
||||
# MAIL_SERVER=smtp.gmail.com
|
||||
# MAIL_PORT=587
|
||||
# MAIL_USE_TLS=true
|
||||
# MAIL_USE_SSL=false
|
||||
# MAIL_USERNAME=your-email@gmail.com
|
||||
# MAIL_PASSWORD=your-app-password
|
||||
# MAIL_DEFAULT_SENDER=noreply@yourdomain.com
|
||||
# MAIL_MAX_EMAILS=100
|
||||
|
||||
# File upload settings
|
||||
MAX_CONTENT_LENGTH=16777216
|
||||
UPLOAD_FOLDER=/data/uploads
|
||||
|
||||
62
migrations/versions/033_add_email_settings.py
Normal file
62
migrations/versions/033_add_email_settings.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Add email configuration settings to Settings model
|
||||
|
||||
Revision ID: 033_add_email_settings
|
||||
Revises: 032_add_api_tokens
|
||||
Create Date: 2025-10-27
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '033_add_email_settings'
|
||||
down_revision = '032_add_api_tokens'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add email configuration columns to settings table"""
|
||||
# Add email configuration columns
|
||||
with op.batch_alter_table('settings', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('mail_enabled', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_server', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_port', sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_use_tls', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_use_ssl', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_username', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_password', sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column('mail_default_sender', sa.String(length=255), nullable=True))
|
||||
|
||||
# Set default values for existing rows
|
||||
op.execute("""
|
||||
UPDATE settings
|
||||
SET mail_enabled = false,
|
||||
mail_port = 587,
|
||||
mail_use_tls = true,
|
||||
mail_use_ssl = false,
|
||||
mail_server = '',
|
||||
mail_username = '',
|
||||
mail_password = '',
|
||||
mail_default_sender = ''
|
||||
WHERE mail_enabled IS NULL
|
||||
""")
|
||||
|
||||
# Make mail_enabled non-nullable after setting defaults
|
||||
with op.batch_alter_table('settings', schema=None) as batch_op:
|
||||
batch_op.alter_column('mail_enabled', nullable=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove email configuration columns from settings table"""
|
||||
with op.batch_alter_table('settings', schema=None) as batch_op:
|
||||
batch_op.drop_column('mail_default_sender')
|
||||
batch_op.drop_column('mail_password')
|
||||
batch_op.drop_column('mail_username')
|
||||
batch_op.drop_column('mail_use_ssl')
|
||||
batch_op.drop_column('mail_use_tls')
|
||||
batch_op.drop_column('mail_port')
|
||||
batch_op.drop_column('mail_server')
|
||||
batch_op.drop_column('mail_enabled')
|
||||
|
||||
201
tests/smoke_test_email.py
Normal file
201
tests/smoke_test_email.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Smoke tests for email functionality
|
||||
|
||||
These tests verify that the email feature is properly integrated and
|
||||
the critical paths work end-to-end.
|
||||
"""
|
||||
import pytest
|
||||
from flask import url_for
|
||||
|
||||
|
||||
class TestEmailSmokeTests:
|
||||
"""Smoke tests for email feature integration"""
|
||||
|
||||
def test_email_support_page_loads(self, client, admin_user):
|
||||
"""Smoke test: Email support page loads without errors"""
|
||||
# Login as admin
|
||||
with client:
|
||||
login_response = client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert login_response.status_code == 200
|
||||
|
||||
# Access email support page
|
||||
response = client.get('/admin/email')
|
||||
|
||||
# Page should load successfully
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check for key elements
|
||||
assert b'Email Configuration' in response.data or b'email' in response.data.lower()
|
||||
assert b'Test Email' in response.data or b'test' in response.data.lower()
|
||||
|
||||
def test_email_configuration_status_api(self, client, admin_user):
|
||||
"""Smoke test: Email configuration status API works"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Get configuration status
|
||||
response = client.get('/admin/email/config-status')
|
||||
|
||||
# API should respond successfully
|
||||
assert response.status_code == 200
|
||||
|
||||
# Response should be JSON
|
||||
data = response.get_json()
|
||||
assert data is not None
|
||||
|
||||
# Should contain required fields
|
||||
assert 'configured' in data
|
||||
assert 'settings' in data
|
||||
assert 'errors' in data
|
||||
assert 'warnings' in data
|
||||
|
||||
def test_admin_dashboard_integration(self, client, admin_user):
|
||||
"""Smoke test: Email feature integrates with admin dashboard"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Access admin dashboard
|
||||
response = client.get('/admin')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Admin dashboard should load successfully
|
||||
assert b'Admin' in response.data
|
||||
|
||||
def test_email_utilities_importable(self):
|
||||
"""Smoke test: Email utilities can be imported"""
|
||||
try:
|
||||
from app.utils.email import (
|
||||
send_email,
|
||||
test_email_configuration,
|
||||
send_test_email,
|
||||
init_mail
|
||||
)
|
||||
# If we can import, test passes
|
||||
assert True
|
||||
except ImportError as e:
|
||||
pytest.fail(f"Failed to import email utilities: {e}")
|
||||
|
||||
def test_email_routes_registered(self, app):
|
||||
"""Smoke test: Email routes are properly registered"""
|
||||
with app.app_context():
|
||||
# Check that email routes exist
|
||||
rules = [rule.rule for rule in app.url_map.iter_rules()]
|
||||
|
||||
# Email support page route
|
||||
assert '/admin/email' in rules
|
||||
|
||||
# Test email route
|
||||
assert '/admin/email/test' in rules
|
||||
|
||||
# Config status route
|
||||
assert '/admin/email/config-status' in rules
|
||||
|
||||
def test_email_template_exists(self, app):
|
||||
"""Smoke test: Email templates exist"""
|
||||
with app.app_context():
|
||||
from flask import render_template
|
||||
|
||||
# Test that admin email support template exists
|
||||
try:
|
||||
# Try to get the template (won't render, just check it exists)
|
||||
from jinja2 import TemplateNotFound
|
||||
try:
|
||||
app.jinja_env.get_template('admin/email_support.html')
|
||||
admin_template_exists = True
|
||||
except TemplateNotFound:
|
||||
admin_template_exists = False
|
||||
|
||||
assert admin_template_exists, "Admin email support template not found"
|
||||
|
||||
# Test that email test template exists
|
||||
try:
|
||||
app.jinja_env.get_template('email/test_email.html')
|
||||
test_template_exists = True
|
||||
except TemplateNotFound:
|
||||
test_template_exists = False
|
||||
|
||||
assert test_template_exists, "Email test template not found"
|
||||
|
||||
except Exception as e:
|
||||
pytest.fail(f"Failed to check templates: {e}")
|
||||
|
||||
def test_email_configuration_with_environment(self, app, monkeypatch):
|
||||
"""Smoke test: Email configuration loads from environment"""
|
||||
# Set test environment variables
|
||||
monkeypatch.setenv('MAIL_SERVER', 'smtp.test.com')
|
||||
monkeypatch.setenv('MAIL_PORT', '587')
|
||||
monkeypatch.setenv('MAIL_USE_TLS', 'true')
|
||||
monkeypatch.setenv('MAIL_DEFAULT_SENDER', 'test@example.com')
|
||||
|
||||
with app.app_context():
|
||||
from app.utils.email import init_mail
|
||||
|
||||
# Initialize mail with environment
|
||||
mail = init_mail(app)
|
||||
|
||||
# Check configuration loaded correctly
|
||||
assert app.config['MAIL_SERVER'] == 'smtp.test.com'
|
||||
assert app.config['MAIL_PORT'] == 587
|
||||
assert app.config['MAIL_USE_TLS'] is True
|
||||
assert app.config['MAIL_DEFAULT_SENDER'] == 'test@example.com'
|
||||
|
||||
|
||||
class TestEmailFeatureIntegrity:
|
||||
"""Tests to verify email feature integrity"""
|
||||
|
||||
def test_all_email_functions_have_docstrings(self):
|
||||
"""Verify all email functions have proper documentation"""
|
||||
from app.utils import email
|
||||
import inspect
|
||||
|
||||
functions = [
|
||||
'send_email',
|
||||
'test_email_configuration',
|
||||
'send_test_email',
|
||||
'init_mail'
|
||||
]
|
||||
|
||||
for func_name in functions:
|
||||
func = getattr(email, func_name, None)
|
||||
assert func is not None, f"Function {func_name} not found"
|
||||
assert func.__doc__ is not None, f"Function {func_name} missing docstring"
|
||||
|
||||
def test_email_routes_have_proper_decorators(self):
|
||||
"""Verify email routes have proper authentication decorators"""
|
||||
from app.routes import admin
|
||||
import inspect
|
||||
|
||||
# Get the email_support function
|
||||
email_support = getattr(admin, 'email_support', None)
|
||||
assert email_support is not None
|
||||
|
||||
# Check that it has route decorator (will be wrapped)
|
||||
# This is a basic check - the route should be registered
|
||||
assert callable(email_support)
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Create an admin user for testing"""
|
||||
from app.models import User
|
||||
user = User(username='admin_smoke', role='admin')
|
||||
user.set_password('password')
|
||||
user.is_active = True
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
201
tests/test_admin_email_routes.py
Normal file
201
tests/test_admin_email_routes.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Tests for admin email routes
|
||||
"""
|
||||
import pytest
|
||||
from flask import url_for
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
class TestAdminEmailRoutes:
|
||||
"""Tests for admin email support routes"""
|
||||
|
||||
def test_email_support_page_requires_login(self, client):
|
||||
"""Test that email support page requires login"""
|
||||
response = client.get('/admin/email')
|
||||
assert response.status_code == 302 # Redirect to login
|
||||
|
||||
def test_email_support_page_requires_admin(self, client, regular_user):
|
||||
"""Test that email support page requires admin permissions"""
|
||||
# Login as regular user
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': regular_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.get('/admin/email')
|
||||
# Should redirect or show error (depends on permission system)
|
||||
assert response.status_code in [302, 403]
|
||||
|
||||
def test_email_support_page_admin_access(self, client, admin_user):
|
||||
"""Test that admin can access email support page"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.get('/admin/email')
|
||||
assert response.status_code == 200
|
||||
assert b'Email Configuration' in response.data or b'email' in response.data.lower()
|
||||
|
||||
@patch('app.routes.admin.test_email_configuration')
|
||||
def test_email_support_shows_configuration_status(self, mock_test_config, client, admin_user):
|
||||
"""Test that email support page shows configuration status"""
|
||||
# Mock configuration status
|
||||
mock_test_config.return_value = {
|
||||
'configured': True,
|
||||
'settings': {
|
||||
'server': 'smtp.gmail.com',
|
||||
'port': 587,
|
||||
'username': 'test@example.com',
|
||||
'password_set': True,
|
||||
'use_tls': True,
|
||||
'use_ssl': False,
|
||||
'default_sender': 'noreply@example.com'
|
||||
},
|
||||
'errors': [],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.get('/admin/email')
|
||||
assert response.status_code == 200
|
||||
# Check that configuration details are displayed
|
||||
assert b'smtp.gmail.com' in response.data or mock_test_config.called
|
||||
|
||||
def test_test_email_endpoint_requires_login(self, client):
|
||||
"""Test that test email endpoint requires login"""
|
||||
response = client.post('/admin/email/test',
|
||||
json={'recipient': 'test@example.com'})
|
||||
assert response.status_code == 302 # Redirect to login
|
||||
|
||||
@patch('app.routes.admin.send_test_email')
|
||||
def test_send_test_email_success(self, mock_send, client, admin_user):
|
||||
"""Test sending test email successfully"""
|
||||
mock_send.return_value = (True, 'Test email sent successfully')
|
||||
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.post('/admin/email/test',
|
||||
json={'recipient': 'test@example.com'},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
assert 'successfully' in data['message'].lower()
|
||||
|
||||
@patch('app.routes.admin.send_test_email')
|
||||
def test_send_test_email_failure(self, mock_send, client, admin_user):
|
||||
"""Test sending test email with failure"""
|
||||
mock_send.return_value = (False, 'Failed to send email: SMTP error')
|
||||
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.post('/admin/email/test',
|
||||
json={'recipient': 'test@example.com'},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 500
|
||||
data = response.get_json()
|
||||
assert data['success'] is False
|
||||
assert 'Failed' in data['message']
|
||||
|
||||
def test_send_test_email_no_recipient(self, client, admin_user):
|
||||
"""Test sending test email without recipient"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.post('/admin/email/test',
|
||||
json={},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert data['success'] is False
|
||||
assert 'required' in data['message'].lower()
|
||||
|
||||
def test_email_config_status_endpoint(self, client, admin_user):
|
||||
"""Test email configuration status endpoint"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
response = client.get('/admin/email/config-status')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'configured' in data
|
||||
assert 'settings' in data
|
||||
assert 'errors' in data
|
||||
assert 'warnings' in data
|
||||
|
||||
def test_rate_limiting_on_test_email(self, client, admin_user):
|
||||
"""Test that test email endpoint has rate limiting"""
|
||||
# Login as admin
|
||||
with client:
|
||||
client.post('/auth/login', data={
|
||||
'username': admin_user.username,
|
||||
'password': 'password'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Send multiple requests rapidly
|
||||
for i in range(6): # Limit is 5 per minute
|
||||
response = client.post('/admin/email/test',
|
||||
json={'recipient': 'test@example.com'},
|
||||
content_type='application/json')
|
||||
|
||||
# After 5 requests, should get rate limited
|
||||
if i >= 5:
|
||||
assert response.status_code == 429 # Too Many Requests
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def regular_user(db):
|
||||
"""Create a regular user"""
|
||||
from app.models import User
|
||||
user = User(username='regular_user', role='user')
|
||||
user.set_password('password')
|
||||
user.is_active = True
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
"""Create an admin user"""
|
||||
from app.models import User
|
||||
user = User(username='admin', role='admin')
|
||||
user.set_password('password')
|
||||
user.is_active = True
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
317
tests/test_email.py
Normal file
317
tests/test_email.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for email functionality
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from flask import current_app
|
||||
from app.utils.email import (
|
||||
send_email,
|
||||
test_email_configuration,
|
||||
send_test_email,
|
||||
init_mail
|
||||
)
|
||||
|
||||
|
||||
class TestEmailConfiguration:
|
||||
"""Tests for email configuration"""
|
||||
|
||||
def test_init_mail(self, app):
|
||||
"""Test email initialization"""
|
||||
with app.app_context():
|
||||
mail = init_mail(app)
|
||||
assert mail is not None
|
||||
assert 'MAIL_SERVER' in app.config
|
||||
assert 'MAIL_PORT' in app.config
|
||||
assert 'MAIL_DEFAULT_SENDER' in app.config
|
||||
|
||||
def test_email_config_status_not_configured(self, app):
|
||||
"""Test email configuration status when not configured"""
|
||||
with app.app_context():
|
||||
# Reset mail server to simulate unconfigured state
|
||||
app.config['MAIL_SERVER'] = 'localhost'
|
||||
|
||||
status = test_email_configuration()
|
||||
|
||||
assert status is not None
|
||||
assert 'configured' in status
|
||||
assert 'settings' in status
|
||||
assert 'errors' in status
|
||||
assert 'warnings' in status
|
||||
assert status['configured'] is False
|
||||
assert len(status['errors']) > 0
|
||||
|
||||
def test_email_config_status_configured(self, app):
|
||||
"""Test email configuration status when properly configured"""
|
||||
with app.app_context():
|
||||
# Set up proper configuration
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
app.config['MAIL_PORT'] = 587
|
||||
app.config['MAIL_USE_TLS'] = True
|
||||
app.config['MAIL_USE_SSL'] = False
|
||||
app.config['MAIL_USERNAME'] = 'test@example.com'
|
||||
app.config['MAIL_PASSWORD'] = 'test_password'
|
||||
app.config['MAIL_DEFAULT_SENDER'] = 'noreply@example.com'
|
||||
|
||||
status = test_email_configuration()
|
||||
|
||||
assert status is not None
|
||||
assert status['configured'] is True
|
||||
assert len(status['errors']) == 0
|
||||
assert status['settings']['server'] == 'smtp.gmail.com'
|
||||
assert status['settings']['port'] == 587
|
||||
assert status['settings']['password_set'] is True
|
||||
|
||||
def test_email_config_warns_about_default_sender(self, app):
|
||||
"""Test that configuration warns about default sender"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
app.config['MAIL_DEFAULT_SENDER'] = 'noreply@timetracker.local'
|
||||
|
||||
status = test_email_configuration()
|
||||
|
||||
assert len(status['warnings']) > 0
|
||||
assert any('Default sender' in w for w in status['warnings'])
|
||||
|
||||
def test_email_config_errors_on_both_tls_and_ssl(self, app):
|
||||
"""Test that configuration errors when both TLS and SSL are enabled"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
app.config['MAIL_USE_TLS'] = True
|
||||
app.config['MAIL_USE_SSL'] = True
|
||||
|
||||
status = test_email_configuration()
|
||||
|
||||
assert len(status['errors']) > 0
|
||||
assert any('TLS and SSL' in e for e in status['errors'])
|
||||
|
||||
|
||||
class TestSendEmail:
|
||||
"""Tests for sending emails"""
|
||||
|
||||
@patch('app.utils.email.mail.send')
|
||||
@patch('app.utils.email.Thread')
|
||||
def test_send_email_success(self, mock_thread, mock_send, app):
|
||||
"""Test sending email successfully"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
|
||||
send_email(
|
||||
subject='Test Subject',
|
||||
recipients=['test@example.com'],
|
||||
text_body='Test body',
|
||||
html_body='<p>Test body</p>'
|
||||
)
|
||||
|
||||
# Verify thread was started for async sending
|
||||
assert mock_thread.called
|
||||
|
||||
def test_send_email_no_server(self, app, caplog):
|
||||
"""Test sending email with no mail server configured"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = None
|
||||
|
||||
send_email(
|
||||
subject='Test Subject',
|
||||
recipients=['test@example.com'],
|
||||
text_body='Test body'
|
||||
)
|
||||
|
||||
# Should log a warning
|
||||
assert 'Mail server not configured' in caplog.text
|
||||
|
||||
def test_send_email_no_recipients(self, app, caplog):
|
||||
"""Test sending email with no recipients"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
|
||||
send_email(
|
||||
subject='Test Subject',
|
||||
recipients=[],
|
||||
text_body='Test body'
|
||||
)
|
||||
|
||||
# Should log a warning
|
||||
assert 'No recipients' in caplog.text
|
||||
|
||||
@patch('app.utils.email.mail.send')
|
||||
def test_send_test_email_success(self, mock_send, app):
|
||||
"""Test sending test email successfully"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
app.config['MAIL_DEFAULT_SENDER'] = 'test@example.com'
|
||||
|
||||
success, message = send_test_email('recipient@example.com', 'Test Sender')
|
||||
|
||||
assert success is True
|
||||
assert 'successfully' in message.lower()
|
||||
assert mock_send.called
|
||||
|
||||
def test_send_test_email_invalid_recipient(self, app):
|
||||
"""Test sending test email with invalid recipient"""
|
||||
with app.app_context():
|
||||
success, message = send_test_email('invalid-email', 'Test Sender')
|
||||
|
||||
assert success is False
|
||||
assert 'Invalid' in message
|
||||
|
||||
def test_send_test_email_no_server(self, app):
|
||||
"""Test sending test email with no mail server"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = None
|
||||
|
||||
success, message = send_test_email('test@example.com', 'Test Sender')
|
||||
|
||||
assert success is False
|
||||
assert 'not configured' in message
|
||||
|
||||
@patch('app.utils.email.mail.send')
|
||||
def test_send_test_email_exception(self, mock_send, app):
|
||||
"""Test sending test email with exception"""
|
||||
with app.app_context():
|
||||
app.config['MAIL_SERVER'] = 'smtp.gmail.com'
|
||||
app.config['MAIL_DEFAULT_SENDER'] = 'test@example.com'
|
||||
|
||||
# Simulate exception
|
||||
mock_send.side_effect = Exception('SMTP error')
|
||||
|
||||
success, message = send_test_email('test@example.com', 'Test Sender')
|
||||
|
||||
assert success is False
|
||||
assert 'Failed' in message
|
||||
|
||||
|
||||
class TestEmailIntegration:
|
||||
"""Integration tests for email functionality"""
|
||||
|
||||
def test_email_configuration_in_app_context(self, app):
|
||||
"""Test that email configuration is available in app context"""
|
||||
with app.app_context():
|
||||
assert hasattr(current_app, 'config')
|
||||
assert 'MAIL_SERVER' in current_app.config
|
||||
assert 'MAIL_PORT' in current_app.config
|
||||
assert 'MAIL_USE_TLS' in current_app.config
|
||||
assert 'MAIL_DEFAULT_SENDER' in current_app.config
|
||||
|
||||
def test_email_settings_from_environment(self, app, monkeypatch):
|
||||
"""Test that email settings are loaded from environment"""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv('MAIL_SERVER', 'smtp.test.com')
|
||||
monkeypatch.setenv('MAIL_PORT', '465')
|
||||
monkeypatch.setenv('MAIL_USE_SSL', 'true')
|
||||
|
||||
# Reinitialize mail with new environment
|
||||
with app.app_context():
|
||||
mail = init_mail(app)
|
||||
|
||||
assert app.config['MAIL_SERVER'] == 'smtp.test.com'
|
||||
assert app.config['MAIL_PORT'] == 465
|
||||
assert app.config['MAIL_USE_SSL'] is True
|
||||
|
||||
|
||||
class TestDatabaseEmailConfiguration:
|
||||
"""Tests for database-backed email configuration"""
|
||||
|
||||
def test_get_mail_config_when_disabled(self, app):
|
||||
"""Test get_mail_config returns None when database config is disabled"""
|
||||
with app.app_context():
|
||||
from app.models import Settings
|
||||
settings = Settings.get_settings()
|
||||
settings.mail_enabled = False
|
||||
settings.mail_server = 'smtp.test.com'
|
||||
|
||||
config = settings.get_mail_config()
|
||||
assert config is None
|
||||
|
||||
def test_get_mail_config_when_enabled(self, app):
|
||||
"""Test get_mail_config returns config when enabled"""
|
||||
with app.app_context():
|
||||
from app.models import Settings
|
||||
settings = Settings.get_settings()
|
||||
settings.mail_enabled = True
|
||||
settings.mail_server = 'smtp.test.com'
|
||||
settings.mail_port = 587
|
||||
settings.mail_use_tls = True
|
||||
settings.mail_use_ssl = False
|
||||
settings.mail_username = 'test@example.com'
|
||||
settings.mail_password = 'test_password'
|
||||
settings.mail_default_sender = 'noreply@example.com'
|
||||
|
||||
config = settings.get_mail_config()
|
||||
|
||||
assert config is not None
|
||||
assert config['MAIL_SERVER'] == 'smtp.test.com'
|
||||
assert config['MAIL_PORT'] == 587
|
||||
assert config['MAIL_USE_TLS'] is True
|
||||
assert config['MAIL_USE_SSL'] is False
|
||||
assert config['MAIL_USERNAME'] == 'test@example.com'
|
||||
assert config['MAIL_PASSWORD'] == 'test_password'
|
||||
assert config['MAIL_DEFAULT_SENDER'] == 'noreply@example.com'
|
||||
|
||||
def test_init_mail_uses_database_config(self, app):
|
||||
"""Test that init_mail uses database settings when available"""
|
||||
with app.app_context():
|
||||
from app.models import Settings
|
||||
from app.utils.email import init_mail
|
||||
|
||||
settings = Settings.get_settings()
|
||||
settings.mail_enabled = True
|
||||
settings.mail_server = 'smtp.database.com'
|
||||
settings.mail_port = 465
|
||||
app.db.session.commit()
|
||||
|
||||
init_mail(app)
|
||||
|
||||
# Should use database settings
|
||||
assert app.config['MAIL_SERVER'] == 'smtp.database.com'
|
||||
assert app.config['MAIL_PORT'] == 465
|
||||
|
||||
def test_reload_mail_config(self, app):
|
||||
"""Test reloading email configuration"""
|
||||
with app.app_context():
|
||||
from app.models import Settings
|
||||
from app.utils.email import reload_mail_config
|
||||
|
||||
# Set up database config
|
||||
settings = Settings.get_settings()
|
||||
settings.mail_enabled = True
|
||||
settings.mail_server = 'smtp.reloaded.com'
|
||||
app.db.session.commit()
|
||||
|
||||
# Reload configuration
|
||||
success = reload_mail_config(app)
|
||||
|
||||
assert success is True
|
||||
assert app.config['MAIL_SERVER'] == 'smtp.reloaded.com'
|
||||
|
||||
def test_test_email_configuration_shows_source(self, app):
|
||||
"""Test that configuration status shows source (database or environment)"""
|
||||
with app.app_context():
|
||||
from app.models import Settings
|
||||
from app.utils.email import test_email_configuration
|
||||
|
||||
# Test with database config
|
||||
settings = Settings.get_settings()
|
||||
settings.mail_enabled = True
|
||||
settings.mail_server = 'smtp.database.com'
|
||||
app.db.session.commit()
|
||||
|
||||
status = test_email_configuration()
|
||||
|
||||
assert 'source' in status
|
||||
assert status['source'] == 'database'
|
||||
|
||||
# Test with environment config
|
||||
settings.mail_enabled = False
|
||||
app.db.session.commit()
|
||||
|
||||
status = test_email_configuration()
|
||||
assert status['source'] == 'environment'
|
||||
|
||||
|
||||
# Fixtures
|
||||
@pytest.fixture
|
||||
def mock_mail_send():
|
||||
"""Mock the mail.send method"""
|
||||
with patch('app.utils.email.mail.send') as mock:
|
||||
yield mock
|
||||
|
||||
Reference in New Issue
Block a user