Files
TimeTracker/app/utils/email.py
T
Dries Peeters 89623682c8 fix(security): sandbox Jinja2 for database-backed PDF and email templates
Add render_sandboxed_string() using SandboxedEnvironment so stored invoice and
quote HTML, ReportLab text templates, admin PDF previews, and invoice email
HTML are not evaluated with Flask's full template globals (mitigating SSTI).

Add regression tests for sandbox behavior and demo user permissions.
2026-04-24 21:13:29 +02:00

1178 lines
42 KiB
Python

"""Email utilities for sending notifications and reports"""
import os
from datetime import datetime, timedelta
from threading import Thread
from flask import current_app, render_template, url_for
from flask_mail import Mail, Message
from app import db
from app.utils.safe_template_render import render_sandboxed_string
mail = Mail()
def init_mail(app):
"""Initialize Flask-Mail with the app
Checks for database settings first, then falls back to environment variables.
Database settings persist between restarts and updates.
"""
# First, load defaults from environment variables (as fallback)
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
# Database settings persist between restarts and updates.
# Use app_context so this works when create_app() is called from gunicorn workers
# (no request context yet), giving SQLite and PostgreSQL the same behavior.
try:
with app.app_context():
from app import db
from app.models import Settings
if db.session.is_active:
settings = Settings.get_settings()
db_config = settings.get_mail_config()
if db_config:
# Database settings take precedence and persist between restarts
app.config.update(db_config)
app.logger.info(
f"✓ Using database email configuration (persistent): {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}"
)
else:
app.logger.info("Using environment variable email configuration (database email not enabled)")
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}")
app.logger.info("Using environment variable email configuration (database unavailable)")
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.
Database settings persist between restarts and updates.
"""
try:
from app.models import Settings
settings = Settings.get_settings()
db_config = settings.get_mail_config()
if db_config:
# Update app configuration with latest database settings
app.config.update(db_config)
# Reinitialize mail with new config (this ensures mail object uses latest settings)
mail.init_app(app)
app.logger.info(
f"✓ Email configuration reloaded from database: {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}"
)
return True
else:
app.logger.info("No database email configuration found, using environment variables")
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_client_portal_password_setup_email(client, token):
"""Send password setup email to client
Args:
client: Client object
token: Password setup token
Returns:
bool: True if email sent successfully, False otherwise
"""
try:
if not client.email:
current_app.logger.warning(f"Cannot send password setup email to client {client.name}: no email address")
return False
# Always check database settings first (they take precedence)
from app.models import Settings
settings = Settings.get_settings()
db_config = settings.get_mail_config()
# Use database config if available, otherwise fall back to app config
if db_config:
mail_server = db_config.get("MAIL_SERVER")
mail_default_sender = db_config.get("MAIL_DEFAULT_SENDER")
# Reload mail config to ensure we're using latest database settings
reload_mail_config(current_app._get_current_object())
else:
mail_server = current_app.config.get("MAIL_SERVER")
mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER")
# Check if email is configured
if not mail_server or mail_server == "localhost":
current_app.logger.error("Mail server not configured. Cannot send password setup email.")
return False
# Generate password setup URL
setup_url = url_for("client_portal.set_password", token=token, _external=True)
# Render email template
html_body = render_template(
"email/client_portal_password_setup.html", client=client, setup_url=setup_url, token=token
)
# Plain text version
text_body = f"""
Hello {client.name or client.contact_person or 'Client'},
You have been granted access to the TimeTracker Client Portal.
To set your password and access the portal, please click the following link:
{setup_url}
This link will expire in 24 hours.
If you did not request this access, please contact your administrator.
Best regards,
TimeTracker Team
"""
subject = f"Set Your Client Portal Password - {client.name}"
# Create message
msg = Message(
subject=subject,
recipients=[client.email],
body=text_body,
html=html_body,
sender=mail_default_sender or current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local"),
)
# Send synchronously to catch errors
try:
mail.send(msg)
current_app.logger.info(
f"Password setup email sent successfully to {client.email} for client {client.name}"
)
return True
except Exception as send_error:
current_app.logger.error(f"Failed to send password setup email: {send_error}")
return False
except Exception as e:
current_app.logger.error(f"Failed to prepare password setup email: {e}")
return False
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
"""
# Always check database settings first (they take precedence)
from app.models import Settings
settings = Settings.get_settings()
db_config = settings.get_mail_config()
# Use database config if available, otherwise fall back to app config
if db_config:
mail_server = db_config.get("MAIL_SERVER")
mail_default_sender = db_config.get("MAIL_DEFAULT_SENDER")
else:
mail_server = current_app.config.get("MAIL_SERVER")
mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER")
if not mail_server or mail_server == "localhost":
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 mail_default_sender
or current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local"),
)
# 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_remind_to_log_email(user):
"""Send a one-line reminder to log time (e.g. end-of-day reminder).
Args:
user: User object (must have email and email_notifications True).
"""
if not user.email or not user.email_notifications or not getattr(user, "notification_remind_to_log", False):
return
subject = "Reminder: log your time today"
dashboard_url = url_for("main.dashboard", _external=True)
text_body = f"""Hello {getattr(user, 'full_name', None) or user.username},
You haven't logged any time today yet. Don't forget to log your work in TimeTracker.
Dashboard: {dashboard_url}
---
TimeTracker
"""
html_body = f"""<p>Hello {getattr(user, 'full_name', None) or user.username},</p>
<p>You haven't logged any time today yet. Don't forget to log your work in TimeTracker.</p>
<p><a href="{dashboard_url}">Open Dashboard</a></p>
<p style="color:#64748b;font-size:0.875rem;">— TimeTracker</p>"""
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 _build_invoice_email_payload(invoice, email_template_id=None, custom_message=None):
"""Build PDF and bodies for an invoice email.
Returns:
tuple: (pdf_bytes, html_body, text_body, subject)
Raises:
ValueError: if PDF generation fails completely.
"""
from app.models import Settings
current_app.logger.info(f"[INVOICE EMAIL] Building payload for invoice {invoice.invoice_number}")
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:")
raise ValueError(f"PDF generation failed: {str(fallback_error)}") from fallback_error
if not pdf_bytes:
raise ValueError("PDF generation returned empty result")
settings = Settings.get_settings()
from app.utils.invoice_pdf_postprocess import postprocess_invoice_pdf_bytes
pdf_bytes, embed_err, pdfa_err = postprocess_invoice_pdf_bytes(pdf_bytes, invoice, settings)
if embed_err:
current_app.logger.error(f"[INVOICE EMAIL] Factur-X embed failed: {embed_err}")
raise ValueError(
f"Factur-X embedding is enabled but failed: {embed_err}. Email not sent so the PDF does not ship without embedded XML."
) from None
if pdfa_err:
current_app.logger.error(f"[INVOICE EMAIL] PDF/A-3 normalization failed: {pdfa_err}")
raise ValueError(f"PDF/A-3 normalization failed: {pdfa_err}. Email not sent.") from None
company_name = settings.company_name if settings else "Your Company"
subject = f"Invoice {invoice.invoice_number} from {company_name}"
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:
template_html = email_template.html.strip()
if email_template.css and email_template.css.strip():
css_content = email_template.css.strip()
if not css_content.startswith("<style"):
css_content = f"<style>\n{css_content}\n</style>"
if "<style>" not in template_html and "</style>" not in template_html:
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}"
if not template_html.strip().startswith("<!DOCTYPE") and not template_html.strip().startswith("<html"):
if "<html" not in template_html:
template_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
{template_html}
</body>
</html>"""
html_body = render_sandboxed_string(
template_html,
autoescape=True,
invoice=invoice,
company_name=company_name,
custom_message=custom_message,
)
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:")
if not html_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}
"""
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
return pdf_bytes, html_body, text_body, subject
def send_invoice_template_test_email(template_id, recipient_email, invoice_id=None, custom_message=None):
"""Send a test email using a saved invoice email template (same rendering as production).
Does not create InvoiceEmail records or change invoice status.
Args:
template_id: InvoiceTemplate id (must exist and have HTML).
recipient_email: Where to send the test.
invoice_id: Optional invoice to use for PDF/context; defaults to latest invoice.
custom_message: Optional custom message for template variables.
Returns:
tuple: (success: bool, message: str)
"""
try:
if not recipient_email or "@" not in recipient_email:
return False, "Invalid recipient email address"
mail_server = current_app.config.get("MAIL_SERVER")
if not mail_server or mail_server == "localhost":
return False, "Mail server not configured"
from app.models import Invoice, InvoiceTemplate
email_template = InvoiceTemplate.query.get(template_id)
if not email_template:
return False, "Email template not found"
if not email_template.html or not email_template.html.strip():
return False, "Email template has no HTML content"
invoice = None
if invoice_id is not None:
invoice = Invoice.query.get(invoice_id)
if not invoice:
return False, "Invoice not found for the given invoice_id."
if not invoice:
invoice = Invoice.query.order_by(Invoice.id.desc()).first()
if not invoice:
return False, "No invoice found. Create an invoice first to test with real data."
pdf_bytes, html_body, text_body, _subject = _build_invoice_email_payload(
invoice, email_template_id=template_id, custom_message=custom_message
)
subject = f"Invoice template test: {email_template.name}"
sender = current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local")
msg = Message(
subject=subject,
recipients=[recipient_email],
body=text_body,
html=html_body,
sender=sender,
)
msg.attach(f"{invoice.invoice_number}.pdf", "application/pdf", pdf_bytes)
current_app.logger.info(f"[INVOICE TEMPLATE TEST] Sending to {recipient_email} for template id={template_id}")
mail.send(msg)
return True, f"Test email sent successfully to {recipient_email}"
except Exception as e:
current_app.logger.error(f"[INVOICE TEMPLATE TEST] Failed: {e}")
current_app.logger.exception("[INVOICE TEMPLATE TEST] 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
current_app.logger.info(f"[INVOICE EMAIL] Sending invoice {invoice.invoice_number} to {recipient_email}")
try:
pdf_bytes, html_body, text_body, subject = _build_invoice_email_payload(
invoice, email_template_id=email_template_id, custom_message=custom_message
)
except ValueError as ve:
return False, None, str(ve)
# 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
# Filename should be template+date+number (invoice number format)
# Send email synchronously to catch errors
attachments = [(f"{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.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local"),
)
# 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)}"
def send_quote_sent_notification(quote, user):
"""Send notification when a quote is sent to client
Args:
quote: Quote object
user: User object (quote creator or admin)
"""
if not user.email or not user.email_notifications:
return
subject = f"Quote {quote.quote_number} has been sent to {quote.client.name if quote.client else 'client'}"
text_body = f"""
Hello {user.display_name or user.username},
Quote {quote.quote_number} has been sent to the client.
Quote Details:
- Quote Number: {quote.quote_number}
- Title: {quote.title}
- Client: {quote.client.name if quote.client else 'N/A'}
- Total Amount: {quote.currency_code} {quote.total_amount}
- Sent At: {quote.sent_at.strftime('%Y-%m-%d %H:%M') if quote.sent_at else 'N/A'}
View quote: {url_for('quotes.view_quote', quote_id=quote.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template("email/quote_sent.html", user=user, quote=quote)
send_email(subject, user.email, text_body, html_body)
def send_quote_accepted_notification(quote, user):
"""Send notification when a quote is accepted
Args:
quote: Quote object
user: User object (quote creator or admin)
"""
if not user.email or not user.email_notifications:
return
subject = f"Quote {quote.quote_number} has been accepted"
text_body = f"""
Hello {user.display_name or user.username},
Great news! Quote {quote.quote_number} has been accepted by the client.
Quote Details:
- Quote Number: {quote.quote_number}
- Title: {quote.title}
- Client: {quote.client.name if quote.client else 'N/A'}
- Total Amount: {quote.currency_code} {quote.total_amount}
- Accepted At: {quote.accepted_at.strftime('%Y-%m-%d %H:%M') if quote.accepted_at else 'N/A'}
- Project: {'Created' if quote.has_project else 'Not yet created'}
View quote: {url_for('quotes.view_quote', quote_id=quote.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template("email/quote_accepted.html", user=user, quote=quote)
send_email(subject, user.email, text_body, html_body)
def send_quote_rejected_notification(quote, user):
"""Send notification when a quote is rejected
Args:
quote: Quote object
user: User object (quote creator or admin)
"""
if not user.email or not user.email_notifications:
return
subject = f"Quote {quote.quote_number} has been rejected"
text_body = f"""
Hello {user.display_name or user.username},
Quote {quote.quote_number} has been rejected by the client.
Quote Details:
- Quote Number: {quote.quote_number}
- Title: {quote.title}
- Client: {quote.client.name if quote.client else 'N/A'}
- Total Amount: {quote.currency_code} {quote.total_amount}
- Rejected At: {quote.rejected_at.strftime('%Y-%m-%d %H:%M') if quote.rejected_at else 'N/A'}
View quote: {url_for('quotes.view_quote', quote_id=quote.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template("email/quote_rejected.html", user=user, quote=quote)
send_email(subject, user.email, text_body, html_body)
def send_quote_expired_notification(quote, user):
"""Send notification when a quote expires
Args:
quote: Quote object
user: User object (quote creator or admin)
"""
if not user.email or not user.email_notifications:
return
subject = f"Quote {quote.quote_number} has expired"
text_body = f"""
Hello {user.display_name or user.username},
Quote {quote.quote_number} has expired.
Quote Details:
- Quote Number: {quote.quote_number}
- Title: {quote.title}
- Client: {quote.client.name if quote.client else 'N/A'}
- Total Amount: {quote.currency_code} {quote.total_amount}
- Valid Until: {quote.valid_until.strftime('%Y-%m-%d') if quote.valid_until else 'N/A'}
You may want to follow up with the client or create a new quote.
View quote: {url_for('quotes.view_quote', quote_id=quote.id, _external=True)}
---
TimeTracker - Time Tracking & Project Management
"""
html_body = render_template("email/quote_expired.html", user=user, quote=quote)
send_email(subject, user.email, text_body, html_body)
def send_quote_email(quote, recipient_email, sender_user=None, custom_message=None):
"""Send a quote via email with PDF attachment
Args:
quote: Quote 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
Returns:
tuple: (success: bool, message: str)
"""
try:
from flask import current_app, render_template
from flask_mail import Message
from app import db, mail
from app.models import Settings
current_app.logger.info(f"[QUOTE EMAIL] Sending quote {quote.quote_number} to {recipient_email}")
# Generate PDF
pdf_bytes = None
try:
# Try to use QuotePDFGenerator if it exists
try:
from app.utils.pdf_generator import QuotePDFGenerator
settings = Settings.get_settings()
pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size="A4")
pdf_bytes = pdf_generator.generate_pdf()
except ImportError:
# Fallback to simple PDF generation
from app.utils.pdf_generator_fallback import QuotePDFGeneratorFallback
settings = Settings.get_settings()
pdf_generator = QuotePDFGeneratorFallback(quote, settings=settings)
pdf_bytes = pdf_generator.generate_pdf()
if not pdf_bytes:
raise ValueError("PDF generator returned None")
current_app.logger.info(f"[QUOTE EMAIL] PDF generated successfully - size: {len(pdf_bytes)} bytes")
except Exception as pdf_error:
current_app.logger.error(f"[QUOTE EMAIL] PDF generation failed: {pdf_error}")
current_app.logger.exception("[QUOTE EMAIL] PDF generation error details:")
return False, f"PDF generation failed: {str(pdf_error)}"
# 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"Quote {quote.quote_number} from {company_name}"
# Create email body
text_body = f"""
Hello,
Please find attached quote {quote.quote_number} for your review.
Quote Details:
- Quote Number: {quote.quote_number}
- Title: {quote.title}
- Valid Until: {quote.valid_until.strftime('%Y-%m-%d') if quote.valid_until else 'N/A'}
- Amount: {quote.currency_code} {quote.total_amount}
"""
if custom_message:
text_body += f"\n{custom_message}\n\n"
text_body += f"""
Please review the attached quote and let us know if you have any questions.
Thank you for your interest!
---
{company_name}
"""
# Render HTML template
html_body = None
try:
html_body = render_template(
"email/quote.html", quote=quote, company_name=company_name, custom_message=custom_message
)
except Exception as template_error:
current_app.logger.warning(f"[QUOTE EMAIL] HTML template not available: {template_error}")
html_body = None
# Send email synchronously to catch errors
attachments = [(f"quote_{quote.quote_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"[QUOTE EMAIL] Attempting to send email to {recipient_email}")
mail.send(msg)
current_app.logger.info(f"[QUOTE EMAIL] ✓ Email sent successfully to {recipient_email}")
except Exception as send_error:
current_app.logger.error(
f"[QUOTE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}"
)
current_app.logger.exception("[QUOTE EMAIL] Email send error details:")
raise send_error
# Mark quote as sent if it's still draft
if quote.status == "draft":
quote.send()
db.session.commit()
return True, "Email sent successfully"
except Exception as e:
current_app.logger.error(f"[QUOTE EMAIL] Exception in send_quote_email: {e}", exc_info=True)
db.session.rollback()
return False, f"Failed to send email: {str(e)}"