Files
TimeTracker/app/utils/email.py
Dries Peeters 543be51c15 feat(admin): improve email configuration UI and logging
Enhance the email support feature with better UX and debugging capabilities:

- **Fix input field styling**: Update all form inputs to use project-standard
  'form-input' class and consistent checkbox styling matching other admin
  pages for uniform appearance across the application

- **Add comprehensive logging**: Implement detailed logging throughout email
  operations with clear prefixes ([EMAIL TEST], [EMAIL CONFIG]) to track:
  - Email configuration changes and validation
  - Test email sending process step-by-step
  - SMTP connection details and status
  - Success/failure indicators (✓/✗) for quick troubleshooting

- **Auto-reload after save**: Page now automatically refreshes 1.5 seconds
  after successfully saving email configuration to ensure UI reflects the
  latest settings and eliminate stale data

These improvements provide better visual consistency, easier debugging of
email issues, and smoother user experience when configuring email settings.

Files modified:
- app/templates/admin/email_support.html
- app/utils/email.py
- app/routes/admin.py
2025-10-27 10:13:58 +01:00

465 lines
16 KiB
Python

"""Email utilities for sending notifications and reports"""
import os
from flask import current_app, render_template, url_for
from flask_mail import Mail, Message
from threading import Thread
from datetime import datetime, timedelta
mail = Mail()
def init_mail(app):
"""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'
app.config['MAIL_USE_SSL'] = os.getenv('MAIL_USE_SSL', 'false').lower() == 'true'
app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME')
app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD')
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():
try:
mail.send(msg)
except Exception as e:
current_app.logger.error(f"Failed to send email: {e}")
def send_email(subject, recipients, text_body, html_body=None, sender=None, attachments=None):
"""Send an email
Args:
subject: Email subject line
recipients: List of recipient email addresses
text_body: Plain text email body
html_body: HTML email body (optional)
sender: Sender email address (optional, uses default if not provided)
attachments: List of (filename, content_type, data) tuples
"""
if not current_app.config.get('MAIL_SERVER'):
current_app.logger.warning("Mail server not configured, skipping email send")
return
if not recipients:
current_app.logger.warning("No recipients specified for email")
return
msg = Message(
subject=subject,
recipients=recipients if isinstance(recipients, list) else [recipients],
body=text_body,
html=html_body,
sender=sender or current_app.config['MAIL_DEFAULT_SENDER']
)
# Add attachments if provided
if attachments:
for filename, content_type, data in attachments:
msg.attach(filename, content_type, data)
# Send asynchronously
Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start()
def send_overdue_invoice_notification(invoice, user):
"""Send notification about an overdue invoice
Args:
invoice: Invoice object
user: User object (invoice creator or admin)
"""
if not user.email or not user.email_notifications or not user.notification_overdue_invoices:
return
days_overdue = (datetime.utcnow().date() - invoice.due_date).days
subject = f"Invoice {invoice.invoice_number} is {days_overdue} days overdue"
text_body = f"""
Hello {user.display_name},
Invoice {invoice.invoice_number} for {invoice.client_name} is now {days_overdue} days overdue.
Invoice Details:
- Invoice Number: {invoice.invoice_number}
- Client: {invoice.client_name}
- Amount: {invoice.currency_code} {invoice.total_amount}
- Due Date: {invoice.due_date}
- Days Overdue: {days_overdue}
Please follow up with the client or update the invoice status.
View invoice: {url_for('invoices.view_invoice', invoice_id=invoice.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template(
'email/overdue_invoice.html',
user=user,
invoice=invoice,
days_overdue=days_overdue
)
send_email(subject, user.email, text_body, html_body)
def send_task_assigned_notification(task, user, assigned_by):
"""Send notification when a user is assigned to a task
Args:
task: Task object
user: User who was assigned
assigned_by: User who made the assignment
"""
if not user.email or not user.email_notifications or not user.notification_task_assigned:
return
subject = f"You've been assigned to task: {task.name}"
text_body = f"""
Hello {user.display_name},
{assigned_by.display_name} has assigned you to a task.
Task Details:
- Task: {task.name}
- Project: {task.project.name if task.project else 'N/A'}
- Priority: {task.priority or 'Normal'}
- Due Date: {task.due_date if task.due_date else 'Not set'}
- Status: {task.status}
Description:
{task.description or 'No description provided'}
View task: {url_for('tasks.edit_task', task_id=task.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template(
'email/task_assigned.html',
user=user,
task=task,
assigned_by=assigned_by
)
send_email(subject, user.email, text_body, html_body)
def send_weekly_summary(user, start_date, end_date, hours_worked, projects_data):
"""Send weekly time tracking summary to user
Args:
user: User object
start_date: Start of the week
end_date: End of the week
hours_worked: Total hours worked
projects_data: List of dicts with project data
"""
if not user.email or not user.email_notifications or not user.notification_weekly_summary:
return
subject = f"Your Weekly Time Summary ({start_date} to {end_date})"
# Build project summary text
project_summary = "\n".join([
f"- {p['name']}: {p['hours']:.1f} hours"
for p in projects_data
])
text_body = f"""
Hello {user.display_name},
Here's your time tracking summary for the week of {start_date} to {end_date}:
Total Hours: {hours_worked:.1f}
Hours by Project:
{project_summary}
Keep up the great work!
View detailed reports: {url_for('reports.reports', _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template(
'email/weekly_summary.html',
user=user,
start_date=start_date,
end_date=end_date,
hours_worked=hours_worked,
projects_data=projects_data
)
send_email(subject, user.email, text_body, html_body)
def send_comment_notification(comment, task, mentioned_users):
"""Send notification about a new comment
Args:
comment: Comment object
task: Task the comment is on
mentioned_users: List of User objects mentioned in the comment
"""
for user in mentioned_users:
if not user.email or not user.email_notifications or not user.notification_task_comments:
continue
subject = f"You were mentioned in a comment on: {task.name}"
text_body = f"""
Hello {user.display_name},
{comment.user.display_name} mentioned you in a comment on task "{task.name}".
Comment:
{comment.content}
Task: {task.name}
Project: {task.project.name if task.project else 'N/A'}
View task: {url_for('tasks.edit_task', task_id=task.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template(
'email/comment_mention.html',
user=user,
comment=comment,
task=task
)
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)}'