"""Email utilities for sending notifications and reports""" import os from flask import current_app, render_template, url_for from jinja2 import Template as JinjaTemplate from flask_mail import Mail, Message from threading import Thread from datetime import datetime, timedelta from app import db 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 check_email_configuration(): """Check 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'), datetime=datetime ) 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)}' def send_invoice_email(invoice, recipient_email, sender_user=None, custom_message=None, email_template_id=None): """Send an invoice via email with PDF attachment Args: invoice: Invoice object recipient_email: Email address to send to sender_user: User object who is sending (for tracking) custom_message: Optional custom message to include in email email_template_id: Optional email template ID to use Returns: tuple: (success: bool, invoice_email: InvoiceEmail or None, message: str) """ try: from app.models import InvoiceEmail, Settings current_app.logger.info(f"[INVOICE EMAIL] Sending invoice {invoice.invoice_number} to {recipient_email}") # Generate PDF pdf_bytes = None try: from app.utils.pdf_generator import InvoicePDFGenerator settings = Settings.get_settings() pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size='A4') pdf_bytes = pdf_generator.generate_pdf() if not pdf_bytes: raise ValueError("PDF generator returned None") current_app.logger.info(f"[INVOICE EMAIL] PDF generated successfully - size: {len(pdf_bytes)} bytes") except Exception as pdf_error: current_app.logger.warning(f"[INVOICE EMAIL] PDF generation failed, trying fallback: {pdf_error}") current_app.logger.exception("[INVOICE EMAIL] PDF generation error details:") try: from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback settings = Settings.get_settings() pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings) pdf_bytes = pdf_generator.generate_pdf() if not pdf_bytes: raise ValueError("PDF fallback generator returned None") current_app.logger.info(f"[INVOICE EMAIL] PDF generated via fallback - size: {len(pdf_bytes)} bytes") except Exception as fallback_error: current_app.logger.error(f"[INVOICE EMAIL] Both PDF generators failed: {fallback_error}") current_app.logger.exception("[INVOICE EMAIL] Fallback PDF generation error details:") return False, None, f'PDF generation failed: {str(fallback_error)}' if not pdf_bytes: current_app.logger.error("[INVOICE EMAIL] PDF bytes is None after generation") return False, None, 'PDF generation returned empty result' # Get settings for email subject/body settings = Settings.get_settings() company_name = settings.company_name if settings else 'Your Company' # Create email subject subject = f"Invoice {invoice.invoice_number} from {company_name}" # Get email template if specified html_body = None text_body = None if email_template_id: try: from app.models import InvoiceTemplate email_template = InvoiceTemplate.query.get(email_template_id) if email_template and email_template.html: # Use custom template # Ensure the HTML is properly formatted for email template_html = email_template.html.strip() # If CSS is provided separately, wrap it in ' # Insert CSS into HTML if not already present if '' not in template_html: # Try to insert before or at the beginning if no head tag if '' in template_html: template_html = template_html.replace('', f'{css_content}\n') elif '' in template_html: template_html = template_html.replace('', f'{css_content}\n') else: template_html = f'{css_content}\n{template_html}' # Ensure HTML has proper structure if not template_html.strip().startswith(' {template_html} ''' template = JinjaTemplate(template_html) html_body = template.render( invoice=invoice, company_name=company_name, custom_message=custom_message ) # Generate text version from HTML if needed text_body = f"Invoice {invoice.invoice_number} - Please see attached PDF for details." except Exception as template_error: current_app.logger.warning(f"[INVOICE EMAIL] Custom template failed: {template_error}") current_app.logger.exception("[INVOICE EMAIL] Template error details:") # Fallback to default template if not html_body: # Create email body text_body = f""" Hello, Please find attached invoice {invoice.invoice_number} for your records. Invoice Details: - Invoice Number: {invoice.invoice_number} - Issue Date: {invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A'} - Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'} - Amount: {invoice.currency_code} {invoice.total_amount} """ if custom_message: text_body += f"\n{custom_message}\n\n" text_body += f""" Please remit payment by the due date. Thank you for your business! --- {company_name} """ # Render HTML template try: html_body = render_template( 'email/invoice.html', invoice=invoice, company_name=company_name, custom_message=custom_message ) except Exception as template_error: current_app.logger.warning(f"[INVOICE EMAIL] HTML template not available: {template_error}") html_body = None # Get sender user ID sender_id = sender_user.id if sender_user else None if not sender_id: # Try to get from invoice creator sender_id = invoice.created_by # Send email synchronously to catch errors attachments = [ (f'invoice_{invoice.invoice_number}.pdf', 'application/pdf', pdf_bytes) ] # Create message msg = Message( subject=subject, recipients=[recipient_email], body=text_body, html=html_body, sender=current_app.config['MAIL_DEFAULT_SENDER'] ) # Add attachments for filename, content_type, data in attachments: msg.attach(filename, content_type, data) # Send synchronously to catch errors try: current_app.logger.info(f"[INVOICE EMAIL] Attempting to send email to {recipient_email}") current_app.logger.debug(f"[INVOICE EMAIL] Email config - Server: {current_app.config.get('MAIL_SERVER')}, Port: {current_app.config.get('MAIL_PORT')}") mail.send(msg) current_app.logger.info(f"[INVOICE EMAIL] ✓ Email sent successfully to {recipient_email}") except Exception as send_error: current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}") current_app.logger.exception("[INVOICE EMAIL] Email send error details:") raise send_error # Create email tracking record invoice_email = InvoiceEmail( invoice_id=invoice.id, recipient_email=recipient_email, subject=subject, sent_by=sender_id ) db.session.add(invoice_email) # Update invoice status to 'sent' if it's still 'draft' if invoice.status == 'draft': invoice.status = 'sent' db.session.commit() return True, invoice_email, f'Invoice email sent successfully to {recipient_email}' except Exception as e: current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send invoice email: {type(e).__name__}: {str(e)}") current_app.logger.exception("[INVOICE EMAIL] Full exception trace:") # Try to create failed tracking record try: from app.models import InvoiceEmail sender_id = sender_user.id if sender_user else invoice.created_by invoice_email = InvoiceEmail( invoice_id=invoice.id, recipient_email=recipient_email, subject=f"Invoice {invoice.invoice_number}", sent_by=sender_id ) invoice_email.mark_failed(str(e)) db.session.add(invoice_email) db.session.commit() except Exception: db.session.rollback() return False, None, f'Failed to send invoice email: {str(e)}'