Merge pull request #164 from DRYTRIX/Feat-MailSupport

feat(admin): improve email configuration UI and logging
This commit is contained in:
Dries Peeters
2025-10-27 10:15:57 +01:00
committed by GitHub
12 changed files with 2328 additions and 2 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>

View 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 %}

View 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>

View File

@@ -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
View 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)

View File

@@ -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

View 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
View 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

View 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
View 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