mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
89623682c8
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.
1178 lines
42 KiB
Python
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)}"
|