diff --git a/app/models/settings.py b/app/models/settings.py index 6bef3f1..f200d56 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -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'' @@ -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 } diff --git a/app/routes/admin.py b/app/routes/admin.py index d6000c3..8ce6d6e 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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 diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 01aeab4..569a919 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -32,6 +32,11 @@
API Tokens
REST API Access
+ + +
Email Configuration
+
Test & Configure
+
Settings
diff --git a/app/templates/admin/email_support.html b/app/templates/admin/email_support.html new file mode 100644 index 0000000..82ac4d7 --- /dev/null +++ b/app/templates/admin/email_support.html @@ -0,0 +1,524 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

{{ _('Email Configuration & Testing') }}

+

{{ _('Configure and test email delivery') }}

+
+
+
+ + +
+

{{ _('Email Configuration') }}

+ +
+

+ + {{ _('Configure email settings here to save them in the database. Database settings take precedence over environment variables.') }} +

+
+ +
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + +
+ + +
+
+

{{ _('Email Configuration Status') }}

+ +
+ +
+ {% if email_status.configured %} + + {% else %} + + {% endif %} + + + {% if email_status.errors %} +
+

+ {{ _('Configuration Errors') }} +

+
    + {% for error in email_status.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + + + {% if email_status.warnings %} +
+

+ {{ _('Configuration Warnings') }} +

+
    + {% for warning in email_status.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} + + +
+

{{ _('Current Email Settings') }}

+
+
+ {{ _('Mail Server') }}: + {{ email_status.settings.server }} +
+
+ {{ _('Port') }}: + {{ email_status.settings.port }} +
+
+ {{ _('Username') }}: + {{ email_status.settings.username }} +
+
+ {{ _('Password Set') }}: + + {% if email_status.settings.password_set %} + {{ _('Yes') }} + {% else %} + {{ _('No') }} + {% endif %} + +
+
+ {{ _('Use TLS') }}: + {{ email_status.settings.use_tls }} +
+
+ {{ _('Use SSL') }}: + {{ email_status.settings.use_ssl }} +
+
+ {{ _('Default Sender') }}: + {{ email_status.settings.default_sender }} +
+
+
+
+
+ + +
+

{{ _('Send Test Email') }}

+ +
+
+ + +
+ + +
+ + + +
+ + +
+

{{ _('Configuration Guide') }}

+ +
+

{{ _('To configure email, set the following environment variables:') }}

+ +
+
# {{ _('Basic SMTP 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
+
+ +

{{ _('Common SMTP Providers') }}

+
    +
  • Gmail: smtp.gmail.com:587 (TLS) - {{ _('Requires app password') }}
  • +
  • Outlook/Office365: smtp.office365.com:587 (TLS)
  • +
  • SendGrid: smtp.sendgrid.net:587 (TLS)
  • +
  • Amazon SES: email-smtp.[region].amazonaws.com:587 (TLS)
  • +
  • Mailgun: smtp.mailgun.org:587 (TLS)
  • +
+ +
+

+ {{ _('Important Notes') }} +

+
    +
  • {{ _('Gmail requires an App Password if 2FA is enabled') }}
  • +
  • {{ _('Restart the application after changing email settings') }}
  • +
  • {{ _('Check firewall rules if emails are not sending') }}
  • +
  • {{ _('For production, use a dedicated email service like SendGrid or Amazon SES') }}
  • +
+
+
+
+ + + +{% endblock %} + diff --git a/app/templates/email/test_email.html b/app/templates/email/test_email.html new file mode 100644 index 0000000..68fce12 --- /dev/null +++ b/app/templates/email/test_email.html @@ -0,0 +1,154 @@ + + + + + + TimeTracker Email Test + + + +
+
+

TimeTracker

+

Email Configuration Test

+
+ +
+ +
+ Email Configuration Working! +
+ +

Hello,

+ +

This is a test email from TimeTracker to verify that your email configuration is working correctly.

+ +

If you received this email, congratulations! Your email settings are properly configured and emails are being delivered successfully.

+ +
+

Test Details

+
+ Sent at: + {{ datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') }} UTC +
+
+ Sent by: + {{ sender_name }} +
+
+ Mail Server: + {{ mail_server }}:{{ mail_port }} +
+
+ TLS Enabled: + {{ 'Yes' if use_tls else 'No' }} +
+
+ SSL Enabled: + {{ 'Yes' if use_ssl else 'No' }} +
+
+ +

You can now use email features in TimeTracker, including:

+ + + +
+ + + diff --git a/app/utils/email.py b/app/utils/email.py index fa015ad..e53dad4 100644 --- a/app/utils/email.py +++ b/app/utils/email.py @@ -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)}' + diff --git a/docs/EMAIL_CONFIGURATION.md b/docs/EMAIL_CONFIGURATION.md new file mode 100644 index 0000000..bc6c5f4 --- /dev/null +++ b/docs/EMAIL_CONFIGURATION.md @@ -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 + + + +

Hello {{ user.display_name }}!

+

{{ message }}

+ + +``` + +## 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='

Welcome to our application!

' +) +``` + +### `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) + diff --git a/env.example b/env.example index c62ecaf..90e931c 100644 --- a/env.example +++ b/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 diff --git a/migrations/versions/033_add_email_settings.py b/migrations/versions/033_add_email_settings.py new file mode 100644 index 0000000..35c9364 --- /dev/null +++ b/migrations/versions/033_add_email_settings.py @@ -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') + diff --git a/tests/smoke_test_email.py b/tests/smoke_test_email.py new file mode 100644 index 0000000..0ec3b5d --- /dev/null +++ b/tests/smoke_test_email.py @@ -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 + diff --git a/tests/test_admin_email_routes.py b/tests/test_admin_email_routes.py new file mode 100644 index 0000000..eac693e --- /dev/null +++ b/tests/test_admin_email_routes.py @@ -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 + diff --git a/tests/test_email.py b/tests/test_email.py new file mode 100644 index 0000000..19c5347 --- /dev/null +++ b/tests/test_email.py @@ -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='

Test body

' + ) + + # 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 +