mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-27 23:19:42 -06:00
Implement comprehensive recurring invoice system and email functionality with admin interface for managing email templates. Features: Recurring invoices with scheduling, invoice email integration with PDF attachments, email template management admin interface Fixes: CSRF tokens, CSS leakage, toast notifications, response body handling, error logging
693 lines
26 KiB
Python
693 lines
26 KiB
Python
"""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 <style> tags
|
|
if email_template.css and email_template.css.strip():
|
|
css_content = email_template.css.strip()
|
|
# Check if CSS is already wrapped in <style> tags
|
|
if not css_content.startswith('<style'):
|
|
css_content = f'<style>\n{css_content}\n</style>'
|
|
# Insert CSS into HTML if not already present
|
|
if '<style>' not in template_html and '</style>' not in template_html:
|
|
# Try to insert before </head> or at the beginning if no head tag
|
|
if '</head>' in template_html:
|
|
template_html = template_html.replace('</head>', f'{css_content}\n</head>')
|
|
elif '<body>' in template_html:
|
|
template_html = template_html.replace('<body>', f'{css_content}\n<body>')
|
|
else:
|
|
template_html = f'{css_content}\n{template_html}'
|
|
|
|
# Ensure HTML has proper structure
|
|
if not template_html.strip().startswith('<!DOCTYPE') and not template_html.strip().startswith('<html'):
|
|
# Wrap in minimal HTML structure if needed
|
|
if '<html' not in template_html:
|
|
template_html = f'''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
</head>
|
|
<body>
|
|
{template_html}
|
|
</body>
|
|
</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)}'
|
|
|