fix: Fix email template editor initialization and JavaScript errors

- Fix script block name from extra_js to scripts_extra to match base.html
- Replace inline onclick handlers with event listeners to fix scope issues
- Fix ReferenceError for toggleViewMode and insertVariable functions
- Improve editor initialization flow with proper script loading detection
- Add error handling and fallback to textarea if Toast UI Editor fails to load
- Add debug logging for troubleshooting initialization issues
- Ensure default templates are editable (no restrictions in backend)
- Add email templates link to admin menu in base.html
- Remove ENV file configuration details from email support page

The editor now properly initializes and all interactive features work correctly.
This commit is contained in:
Dries Peeters
2025-11-14 13:40:00 +01:00
parent f54ab9934f
commit a4797b25ac
8 changed files with 1677 additions and 52 deletions

View File

@@ -1,5 +1,11 @@
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
return now_in_app_timezone().replace(tzinfo=None)
class InvoiceEmail(db.Model):
@@ -13,7 +19,7 @@ class InvoiceEmail(db.Model):
# Email details
recipient_email = db.Column(db.String(200), nullable=False)
subject = db.Column(db.String(500), nullable=False)
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
sent_at = db.Column(db.DateTime, nullable=False, default=local_now)
sent_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Tracking
@@ -31,8 +37,8 @@ class InvoiceEmail(db.Model):
error_message = db.Column(db.Text, nullable=True) # Error message if send failed
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
# Relationships
invoice = db.relationship('Invoice', backref='email_records')
@@ -52,8 +58,8 @@ class InvoiceEmail(db.Model):
def mark_opened(self):
"""Mark email as opened"""
if not self.opened_at:
self.opened_at = datetime.utcnow()
self.last_opened_at = datetime.utcnow()
self.opened_at = local_now()
self.last_opened_at = local_now()
self.opened_count += 1
if self.status == 'sent':
self.status = 'opened'
@@ -61,7 +67,7 @@ class InvoiceEmail(db.Model):
def mark_paid(self):
"""Mark invoice as paid (after email was sent)"""
if not self.paid_at:
self.paid_at = datetime.utcnow()
self.paid_at = local_now()
self.status = 'paid'
def mark_failed(self, error_message):

View File

@@ -214,7 +214,13 @@ def view_invoice(invoice_id):
# Get email templates for selection
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates)
# Get email history
from app.models import InvoiceEmail
email_history = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\
.order_by(InvoiceEmail.sent_at.desc())\
.all()
return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates, email_history=email_history)
@invoices_bp.route('/invoices/<int:invoice_id>/edit', methods=['GET', 'POST'])
@login_required
@@ -989,3 +995,85 @@ def send_invoice_email_route(invoice_id):
logger.error(f"Error sending invoice email: {type(e).__name__}: {str(e)}")
logger.exception("Full error traceback:")
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500
@invoices_bp.route('/invoices/<int:invoice_id>/email-history', methods=['GET'])
@login_required
def get_invoice_email_history(invoice_id):
"""Get email history for an invoice"""
invoice = Invoice.query.get_or_404(invoice_id)
# Check access permissions
if not current_user.is_admin and invoice.created_by != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
from app.models import InvoiceEmail
# Get all email records for this invoice, ordered by most recent first
email_records = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\
.order_by(InvoiceEmail.sent_at.desc())\
.all()
# Convert to list of dictionaries
email_history = [email.to_dict() for email in email_records]
return jsonify({
'success': True,
'email_history': email_history,
'count': len(email_history)
})
@invoices_bp.route('/invoices/<int:invoice_id>/resend-email/<int:email_id>', methods=['POST'])
@login_required
def resend_invoice_email(invoice_id, email_id):
"""Resend an invoice email"""
invoice = Invoice.query.get_or_404(invoice_id)
# Check access permissions
if not current_user.is_admin and invoice.created_by != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
from app.models import InvoiceEmail
original_email = InvoiceEmail.query.get_or_404(email_id)
# Verify the email belongs to this invoice
if original_email.invoice_id != invoice_id:
return jsonify({'error': 'Email record does not belong to this invoice'}), 400
# Get recipient email from request or use original
recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else ''
if not recipient_email:
recipient_email = original_email.recipient_email
# Get custom message if provided
custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '')
# Get email template ID if provided
email_template_id = request.form.get('email_template_id', type=int) or (request.json.get('email_template_id') if request.is_json else None)
try:
from app.utils.email import send_invoice_email
success, invoice_email, message = send_invoice_email(
invoice=invoice,
recipient_email=recipient_email,
sender_user=current_user,
custom_message=custom_message if custom_message else None,
email_template_id=email_template_id
)
if success:
flash(f'Invoice email resent successfully to {recipient_email}', 'success')
return jsonify({
'success': True,
'message': message,
'invoice_email_id': invoice_email.id if invoice_email else None
})
else:
return jsonify({'error': message}), 500
except Exception as e:
logger.error(f"Error resending invoice email: {type(e).__name__}: {str(e)}")
logger.exception("Full error traceback:")
return jsonify({'error': f'Failed to resend email: {str(e)}'}), 500

View File

@@ -20,7 +20,7 @@
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 rounded-lg">
<p class="text-sm">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure email settings here to save them in the database. Database settings take precedence over environment variables.') }}
{{ _('Configure email settings here to save them in the database.') }}
</p>
</div>
@@ -119,7 +119,7 @@
<i class="fas fa-exclamation-triangle text-xl mr-3"></i>
<div>
<strong class="font-bold">{{ _('Email is not configured') }}</strong>
<span class="block sm:inline">{{ _('Please configure email settings in your environment variables.') }}</span>
<span class="block sm:inline">{{ _('Please configure email settings using the form above.') }}</span>
</div>
</div>
</div>
@@ -228,23 +228,6 @@
<h2 class="text-lg font-semibold mb-4">{{ _('Configuration Guide') }}</h2>
<div class="prose dark:prose-invert max-w-none">
<p class="mb-4">{{ _('To configure email, set the following environment variables:') }}</p>
<div class="bg-bg-light dark:bg-bg-dark border border-border-light dark:border-border-dark rounded-lg p-4 mb-4 font-mono text-sm overflow-x-auto">
<div># {{ _('Basic SMTP Settings') }}</div>
<div>MAIL_SERVER=smtp.gmail.com</div>
<div>MAIL_PORT=587</div>
<div>MAIL_USE_TLS=true</div>
<div>MAIL_USE_SSL=false</div>
<div><br></div>
<div># {{ _('Authentication') }}</div>
<div>MAIL_USERNAME=your-email@gmail.com</div>
<div>MAIL_PASSWORD=your-app-password</div>
<div><br></div>
<div># {{ _('Sender Information') }}</div>
<div>MAIL_DEFAULT_SENDER=noreply@yourdomain.com</div>
</div>
<h3 class="text-base font-semibold mb-2">{{ _('Common SMTP Providers') }}</h3>
<ul class="list-disc list-inside space-y-2 mb-4">
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS) - {{ _('Requires app password') }}</li>

View File

@@ -16,7 +16,7 @@
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<form method="POST" id="emailTemplateForm" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -38,27 +38,490 @@
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
</label>
</div>
<div class="md:col-span-2">
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ html or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<!-- Visual Editor Section -->
<div class="mt-6">
<div class="flex items-center justify-between mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">HTML Template *</label>
<div class="flex gap-2" id="variableButtons">
<button type="button" data-variable="invoice.invoice_number" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Invoice Number
</button>
<button type="button" data-variable="company_name" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Company Name
</button>
<button type="button" data-variable="custom_message" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Custom Message
</button>
<button type="button" id="showAllVariablesBtn" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
More Variables
</button>
</div>
</div>
<div class="md:col-span-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
<!-- Split View: Editor and Preview -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Editor Column -->
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Editor</span>
<div class="flex gap-2">
<button type="button" id="viewModeBtn" class="px-2 py-1 text-xs bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<i class="fas fa-code mr-1"></i>Code
</button>
<button type="button" id="updatePreviewBtn" class="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">
<i class="fas fa-sync mr-1"></i>Preview
</button>
</div>
</div>
<div id="html_editor" style="min-height: 500px;"></div>
<textarea id="html" name="html" required style="display: none;">{{ html or '' }}</textarea>
</div>
<!-- Preview Column -->
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Live Preview</span>
<button type="button" id="refreshPreviewBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
<i class="fas fa-refresh mr-1"></i>Refresh
</button>
</div>
<div id="email_preview" class="p-4 bg-white dark:bg-gray-800 overflow-auto" style="min-height: 500px; max-height: 500px;">
<div class="text-gray-500 dark:text-gray-400 text-center py-8">
<i class="fas fa-eye-slash text-4xl mb-2"></i>
<p>Preview will appear here</p>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Use Jinja2 syntax for variables: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<div class="flex justify-end gap-3">
<!-- CSS Editor Section -->
<div class="mt-6">
<div class="flex items-center justify-between mb-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSS Styles (Optional)</label>
<button type="button" id="applyCssBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
<i class="fas fa-refresh mr-1"></i>Apply CSS
</button>
</div>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-300 dark:border-gray-600">
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Create Template') }}</button>
</div>
</form>
</div>
<!-- Variable Reference Modal -->
<div id="variablesModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Available Variables</h3>
<button id="closeVariablesModalBtn" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Invoice Variables</h4>
<div class="space-y-1">
<button data-variable="invoice.invoice_number" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.invoice_number }}' }}</code>
</button>
<button data-variable="invoice.issue_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.issue_date }}' }}</code>
</button>
<button data-variable="invoice.due_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.due_date }}' }}</code>
</button>
<button data-variable="invoice.total_amount" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.total_amount }}' }}</code>
</button>
<button data-variable="invoice.currency_code" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.currency_code }}' }}</code>
</button>
<button data-variable="invoice.client_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.client_name }}' }}</code>
</button>
</div>
</div>
<div>
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Other Variables</h4>
<div class="space-y-1">
<button data-variable="company_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ company_name }}' }}</code>
</button>
<button data-variable="custom_message" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ custom_message }}' }}</code>
</button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button id="closeVariablesModalBtn2" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
<style>
#email_preview {
font-family: Arial, sans-serif;
}
#email_preview img {
max-width: 100%;
height: auto;
}
</style>
{% endblock %}
{% block scripts_extra %}
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script>
let htmlEditor = null;
let isCodeMode = false;
let originalHtml = {{ (html or '') | tojson }};
// Wait for DOM and Toast UI Editor to be ready
function initEditor() {
console.log('initEditor called');
const htmlTextarea = document.getElementById('html');
const editorContainer = document.getElementById('html_editor');
console.log('htmlTextarea:', htmlTextarea);
console.log('editorContainer:', editorContainer);
if (!editorContainer) {
console.error('Editor container not found');
return;
}
if (!window.toastui || !window.toastui.Editor) {
console.error('Toast UI Editor not loaded');
// Fallback to textarea
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
const fallbackTextarea = document.getElementById('html_fallback');
if (fallbackTextarea && htmlTextarea) {
fallbackTextarea.addEventListener('input', function() {
htmlTextarea.value = this.value;
updatePreview();
});
}
setupEventListeners();
return;
}
try {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
htmlEditor = new toastui.Editor({
el: editorContainer,
height: '500px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: originalHtml || ''
});
// Update hidden textarea on form submit
const emailTemplateForm = document.getElementById('emailTemplateForm');
if (emailTemplateForm) {
emailTemplateForm.addEventListener('submit', function() {
if (htmlEditor) {
try {
htmlTextarea.value = htmlEditor.getHTML();
} catch (e) {
htmlTextarea.value = htmlEditor.getMarkdown();
}
}
});
}
// Auto-update preview on content change
htmlEditor.on('change', function() {
// Debounce preview updates
clearTimeout(window.previewTimeout);
window.previewTimeout = setTimeout(updatePreview, 500);
});
// Initial preview after a short delay
setTimeout(updatePreview, 300);
setupEventListeners();
} catch (error) {
console.error('Error initializing editor:', error);
initializeFallback();
}
}
function initializeFallback() {
const editorContainer = document.getElementById('html_editor');
const htmlTextarea = document.getElementById('html');
if (editorContainer && htmlTextarea) {
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
const fallbackTextarea = document.getElementById('html_fallback');
if (fallbackTextarea) {
fallbackTextarea.addEventListener('input', function() {
htmlTextarea.value = this.value;
updatePreview();
});
}
setupEventListeners();
}
}
function setupEventListeners() {
// CSS editor change handler
const cssTextarea = document.getElementById('css');
if (cssTextarea) {
cssTextarea.addEventListener('input', function() {
clearTimeout(window.previewTimeout);
window.previewTimeout = setTimeout(updatePreview, 500);
});
}
// Set up event listeners for variable buttons
document.querySelectorAll('.variable-btn').forEach(btn => {
btn.addEventListener('click', function() {
const varName = this.getAttribute('data-variable');
insertVariable(varName);
});
});
const showAllVariablesBtn = document.getElementById('showAllVariablesBtn');
if (showAllVariablesBtn) {
showAllVariablesBtn.addEventListener('click', showAllVariables);
}
const closeVariablesModalBtn = document.getElementById('closeVariablesModalBtn');
if (closeVariablesModalBtn) {
closeVariablesModalBtn.addEventListener('click', closeVariablesModal);
}
const closeVariablesModalBtn2 = document.getElementById('closeVariablesModalBtn2');
if (closeVariablesModalBtn2) {
closeVariablesModalBtn2.addEventListener('click', closeVariablesModal);
}
document.querySelectorAll('.modal-variable-btn').forEach(btn => {
btn.addEventListener('click', function() {
const varName = this.getAttribute('data-variable');
insertVariable(varName);
closeVariablesModal();
});
});
// Set up event listeners for preview and view mode buttons
const viewModeBtn = document.getElementById('viewModeBtn');
if (viewModeBtn) {
viewModeBtn.addEventListener('click', toggleViewMode);
}
const updatePreviewBtn = document.getElementById('updatePreviewBtn');
if (updatePreviewBtn) {
updatePreviewBtn.addEventListener('click', updatePreview);
}
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
if (refreshPreviewBtn) {
refreshPreviewBtn.addEventListener('click', updatePreview);
}
const applyCssBtn = document.getElementById('applyCssBtn');
if (applyCssBtn) {
applyCssBtn.addEventListener('click', updatePreview);
}
}
function toggleViewMode() {
if (!htmlEditor) return;
isCodeMode = !isCodeMode;
const btn = document.getElementById('viewModeBtn');
if (isCodeMode) {
// Switch to markdown/code view
htmlEditor.changeMode('markdown');
btn.innerHTML = '<i class="fas fa-eye mr-1"></i>Visual';
} else {
// Switch to WYSIWYG view
htmlEditor.changeMode('wysiwyg');
btn.innerHTML = '<i class="fas fa-code mr-1"></i>Code';
}
}
function insertVariable(varName) {
if (!htmlEditor) {
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
if (textarea) {
const cursorPos = textarea.selectionStart || textarea.value.length;
const textBefore = textarea.value.substring(0, cursorPos);
const textAfter = textarea.value.substring(cursorPos);
const variable = '{{ ' + varName + ' }}';
textarea.value = textBefore + variable + textAfter;
textarea.focus();
textarea.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
updatePreview();
}
return;
}
const variable = '{{ ' + varName + ' }}';
try {
if (isCodeMode || htmlEditor.getCurrentMode() === 'markdown') {
htmlEditor.insertText(variable);
} else {
htmlEditor.insertText(variable);
}
updatePreview();
} catch (e) {
console.error('Error inserting variable:', e);
}
}
function showAllVariables() {
document.getElementById('variablesModal').classList.remove('hidden');
}
function closeVariablesModal() {
document.getElementById('variablesModal').classList.add('hidden');
}
function updatePreview() {
if (!htmlEditor) {
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
if (textarea) {
updatePreviewContent(textarea.value);
}
return;
}
let htmlContent = '';
try {
htmlContent = htmlEditor.getHTML();
} catch (e) {
try {
htmlContent = htmlEditor.getMarkdown();
} catch (e2) {
console.error('Error getting editor content:', e2);
return;
}
}
updatePreviewContent(htmlContent);
}
function updatePreviewContent(htmlContent) {
const previewDiv = document.getElementById('email_preview');
const cssTextarea = document.getElementById('css');
const cssContent = cssTextarea ? cssTextarea.value : '';
if (!previewDiv) {
console.error('Preview div not found');
return;
}
// Create a complete HTML document with CSS
let fullHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8">';
if (cssContent) {
fullHtml += '<style>' + cssContent + '</style>';
}
fullHtml += '</head><body>';
// Replace Jinja2 variables with sample data for preview
htmlContent = (htmlContent || '')
.replace(/\{\{\s*invoice\.invoice_number\s*\}\}/g, 'INV-2024-001')
.replace(/\{\{\s*invoice\.issue_date\s*\}\}/g, '2024-01-15')
.replace(/\{\{\s*invoice\.due_date\s*\}\}/g, '2024-02-15')
.replace(/\{\{\s*invoice\.total_amount\s*\}\}/g, '1,200.00')
.replace(/\{\{\s*invoice\.currency_code\s*\}\}/g, 'EUR')
.replace(/\{\{\s*invoice\.client_name\s*\}\}/g, 'Sample Client')
.replace(/\{\{\s*company_name\s*\}\}/g, 'Your Company Name')
.replace(/\{\{\s*custom_message\s*\}\}/g, 'Thank you for your business!');
fullHtml += htmlContent;
fullHtml += '</body></html>';
// Create iframe for safe preview
previewDiv.innerHTML = '<iframe id="preview_iframe" style="width: 100%; height: 100%; border: none;"></iframe>';
const iframe = document.getElementById('preview_iframe');
if (iframe) {
try {
iframe.contentDocument.open();
iframe.contentDocument.write(fullHtml);
iframe.contentDocument.close();
} catch (e) {
console.error('Error writing to iframe:', e);
previewDiv.innerHTML = '<div class="text-red-500 p-4">Preview unavailable. Please check browser console.</div>';
}
}
}
// Close modal on outside click
document.addEventListener('click', function(event) {
const modal = document.getElementById('variablesModal');
if (event.target == modal) {
closeVariablesModal();
}
});
// Initialize when DOM and Toast UI Editor are ready
function waitForEditorAndInit() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
checkEditorAndInit();
});
} else {
checkEditorAndInit();
}
}
function checkEditorAndInit() {
console.log('checkEditorAndInit called');
console.log('window.toastui:', window.toastui);
console.log('document.readyState:', document.readyState);
// Check if Toast UI Editor is loaded
if (window.toastui && window.toastui.Editor) {
console.log('Toast UI Editor found, initializing...');
setTimeout(initEditor, 50);
} else {
console.log('Toast UI Editor not found, waiting...');
// Wait a bit and try again
setTimeout(function() {
if (window.toastui && window.toastui.Editor) {
console.log('Toast UI Editor found after wait, initializing...');
initEditor();
} else {
console.warn('Toast UI Editor not loaded, using fallback');
initEditor(); // Will use fallback
}
}, 500);
}
}
waitForEditorAndInit();
</script>
{% endblock %}

View File

@@ -16,7 +16,7 @@
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<form method="POST" class="space-y-6">
<form method="POST" id="emailTemplateForm" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -36,27 +36,493 @@
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
</label>
</div>
<div class="md:col-span-2">
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.html or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<!-- Visual Editor Section -->
<div class="mt-6">
<div class="flex items-center justify-between mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">HTML Template *</label>
<div class="flex gap-2" id="variableButtons">
<button type="button" data-variable="invoice.invoice_number" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Invoice Number
</button>
<button type="button" data-variable="company_name" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Company Name
</button>
<button type="button" data-variable="custom_message" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
Custom Message
</button>
<button type="button" id="showAllVariablesBtn" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
More Variables
</button>
</div>
</div>
<div class="md:col-span-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
<!-- Split View: Editor and Preview -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Editor Column -->
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Editor</span>
<div class="flex gap-2">
<button type="button" id="viewModeBtn" class="px-2 py-1 text-xs bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<i class="fas fa-code mr-1"></i>Code
</button>
<button type="button" id="updatePreviewBtn" class="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">
<i class="fas fa-sync mr-1"></i>Preview
</button>
</div>
</div>
<div id="html_editor" style="min-height: 500px;"></div>
<textarea id="html" name="html" required style="display: none;">{{ template.html or '' }}</textarea>
</div>
<!-- Preview Column -->
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Live Preview</span>
<button type="button" id="refreshPreviewBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
<i class="fas fa-refresh mr-1"></i>Refresh
</button>
</div>
<div id="email_preview" class="p-4 bg-white dark:bg-gray-800 overflow-auto" style="min-height: 500px; max-height: 500px;">
<div class="text-gray-500 dark:text-gray-400 text-center py-8">
<i class="fas fa-eye-slash text-4xl mb-2"></i>
<p>Preview will appear here</p>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Use Jinja2 syntax for variables: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
</p>
</div>
<div class="flex justify-end gap-3">
<!-- CSS Editor Section -->
<div class="mt-6">
<div class="flex items-center justify-between mb-2">
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSS Styles (Optional)</label>
<button type="button" id="applyCssBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
<i class="fas fa-refresh mr-1"></i>Apply CSS
</button>
</div>
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.css or '' }}</textarea>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in &lt;style&gt; tags.</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-300 dark:border-gray-600">
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Update Template') }}</button>
</div>
</form>
</div>
<!-- Variable Reference Modal -->
<div id="variablesModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white dark:bg-gray-800">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Available Variables</h3>
<button id="closeVariablesModalBtn" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Invoice Variables</h4>
<div class="space-y-1">
<button data-variable="invoice.invoice_number" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.invoice_number }}' }}</code>
</button>
<button data-variable="invoice.issue_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.issue_date }}' }}</code>
</button>
<button data-variable="invoice.due_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.due_date }}' }}</code>
</button>
<button data-variable="invoice.total_amount" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.total_amount }}' }}</code>
</button>
<button data-variable="invoice.currency_code" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.currency_code }}' }}</code>
</button>
<button data-variable="invoice.client_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ invoice.client_name }}' }}</code>
</button>
</div>
</div>
<div>
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Other Variables</h4>
<div class="space-y-1">
<button data-variable="company_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ company_name }}' }}</code>
</button>
<button data-variable="custom_message" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
<code>{{ '{{ custom_message }}' }}</code>
</button>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<button id="closeVariablesModalBtn2" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
<style>
#email_preview {
font-family: Arial, sans-serif;
}
#email_preview img {
max-width: 100%;
height: auto;
}
</style>
{% endblock %}
{% block scripts_extra %}
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<script>
let htmlEditor = null;
let isCodeMode = false;
let originalHtml = {{ (template.html or '') | tojson }};
// Wait for DOM and Toast UI Editor to be ready
function initEditor() {
console.log('initEditor called');
const htmlTextarea = document.getElementById('html');
const editorContainer = document.getElementById('html_editor');
console.log('htmlTextarea:', htmlTextarea);
console.log('editorContainer:', editorContainer);
if (!editorContainer) {
console.error('Editor container not found');
return;
}
if (!window.toastui || !window.toastui.Editor) {
console.error('Toast UI Editor not loaded');
// Fallback to textarea
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
const fallbackTextarea = document.getElementById('html_fallback');
if (fallbackTextarea && htmlTextarea) {
fallbackTextarea.addEventListener('input', function() {
htmlTextarea.value = this.value;
updatePreview();
});
}
setupEventListeners();
return;
}
try {
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
htmlEditor = new toastui.Editor({
el: editorContainer,
height: '500px',
initialEditType: 'wysiwyg',
previewStyle: 'vertical',
usageStatistics: false,
theme: theme,
toolbarItems: [
['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'],
['ul', 'ol', 'task'],
['link', 'code', 'codeblock', 'table'],
['image'],
['scrollSync']
],
initialValue: originalHtml || ''
});
// Update hidden textarea on form submit
const emailTemplateForm = document.getElementById('emailTemplateForm');
if (emailTemplateForm) {
emailTemplateForm.addEventListener('submit', function() {
if (htmlEditor) {
try {
htmlTextarea.value = htmlEditor.getHTML();
} catch (e) {
htmlTextarea.value = htmlEditor.getMarkdown();
}
}
});
}
// Auto-update preview on content change
htmlEditor.on('change', function() {
clearTimeout(window.previewTimeout);
window.previewTimeout = setTimeout(updatePreview, 500);
});
// Initial preview after a short delay
setTimeout(updatePreview, 300);
setupEventListeners();
} catch (error) {
console.error('Error initializing editor:', error);
initializeFallback();
}
}
function initializeFallback() {
const editorContainer = document.getElementById('html_editor');
const htmlTextarea = document.getElementById('html');
if (editorContainer && htmlTextarea) {
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
const fallbackTextarea = document.getElementById('html_fallback');
if (fallbackTextarea) {
fallbackTextarea.addEventListener('input', function() {
htmlTextarea.value = this.value;
updatePreview();
});
}
setupEventListeners();
}
}
function setupEventListeners() {
// CSS editor change handler
const cssTextarea = document.getElementById('css');
if (cssTextarea) {
cssTextarea.addEventListener('input', function() {
clearTimeout(window.previewTimeout);
window.previewTimeout = setTimeout(updatePreview, 500);
});
}
// Set up event listeners for variable buttons
document.querySelectorAll('.variable-btn').forEach(btn => {
btn.addEventListener('click', function() {
const varName = this.getAttribute('data-variable');
insertVariable(varName);
});
});
const showAllVariablesBtn = document.getElementById('showAllVariablesBtn');
if (showAllVariablesBtn) {
showAllVariablesBtn.addEventListener('click', showAllVariables);
}
const closeVariablesModalBtn = document.getElementById('closeVariablesModalBtn');
if (closeVariablesModalBtn) {
closeVariablesModalBtn.addEventListener('click', closeVariablesModal);
}
const closeVariablesModalBtn2 = document.getElementById('closeVariablesModalBtn2');
if (closeVariablesModalBtn2) {
closeVariablesModalBtn2.addEventListener('click', closeVariablesModal);
}
document.querySelectorAll('.modal-variable-btn').forEach(btn => {
btn.addEventListener('click', function() {
const varName = this.getAttribute('data-variable');
insertVariable(varName);
closeVariablesModal();
});
});
// Set up event listeners for preview and view mode buttons
const viewModeBtn = document.getElementById('viewModeBtn');
if (viewModeBtn) {
viewModeBtn.addEventListener('click', toggleViewMode);
}
const updatePreviewBtn = document.getElementById('updatePreviewBtn');
if (updatePreviewBtn) {
updatePreviewBtn.addEventListener('click', updatePreview);
}
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
if (refreshPreviewBtn) {
refreshPreviewBtn.addEventListener('click', updatePreview);
}
const applyCssBtn = document.getElementById('applyCssBtn');
if (applyCssBtn) {
applyCssBtn.addEventListener('click', updatePreview);
}
}
function toggleViewMode() {
if (!htmlEditor) return;
isCodeMode = !isCodeMode;
const btn = document.getElementById('viewModeBtn');
if (isCodeMode) {
htmlEditor.changeMode('markdown');
btn.innerHTML = '<i class="fas fa-eye mr-1"></i>Visual';
} else {
htmlEditor.changeMode('wysiwyg');
btn.innerHTML = '<i class="fas fa-code mr-1"></i>Code';
}
}
function insertVariable(varName) {
if (!htmlEditor) {
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
if (textarea) {
const cursorPos = textarea.selectionStart || textarea.value.length;
const textBefore = textarea.value.substring(0, cursorPos);
const textAfter = textarea.value.substring(cursorPos);
const variable = '{{ ' + varName + ' }}';
textarea.value = textBefore + variable + textAfter;
textarea.focus();
textarea.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
updatePreview();
}
return;
}
const variable = '{{ ' + varName + ' }}';
try {
if (isCodeMode || htmlEditor.getCurrentMode() === 'markdown') {
htmlEditor.insertText(variable);
} else {
htmlEditor.insertText(variable);
}
updatePreview();
} catch (e) {
console.error('Error inserting variable:', e);
}
}
function showAllVariables() {
const modal = document.getElementById('variablesModal');
if (modal) {
modal.classList.remove('hidden');
}
}
function closeVariablesModal() {
const modal = document.getElementById('variablesModal');
if (modal) {
modal.classList.add('hidden');
}
}
function updatePreview() {
if (!htmlEditor) {
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
if (textarea) {
updatePreviewContent(textarea.value);
}
return;
}
let htmlContent = '';
try {
htmlContent = htmlEditor.getHTML();
} catch (e) {
try {
htmlContent = htmlEditor.getMarkdown();
} catch (e2) {
console.error('Error getting editor content:', e2);
return;
}
}
updatePreviewContent(htmlContent);
}
function updatePreviewContent(htmlContent) {
const previewDiv = document.getElementById('email_preview');
const cssTextarea = document.getElementById('css');
const cssContent = cssTextarea ? cssTextarea.value : '';
if (!previewDiv) {
console.error('Preview div not found');
return;
}
// Create a complete HTML document with CSS
let fullHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8">';
if (cssContent) {
fullHtml += '<style>' + cssContent + '</style>';
}
fullHtml += '</head><body>';
// Replace Jinja2 variables with sample data for preview
htmlContent = (htmlContent || '')
.replace(/\{\{\s*invoice\.invoice_number\s*\}\}/g, 'INV-2024-001')
.replace(/\{\{\s*invoice\.issue_date\s*\}\}/g, '2024-01-15')
.replace(/\{\{\s*invoice\.due_date\s*\}\}/g, '2024-02-15')
.replace(/\{\{\s*invoice\.total_amount\s*\}\}/g, '1,200.00')
.replace(/\{\{\s*invoice\.currency_code\s*\}\}/g, 'EUR')
.replace(/\{\{\s*invoice\.client_name\s*\}\}/g, 'Sample Client')
.replace(/\{\{\s*company_name\s*\}\}/g, 'Your Company Name')
.replace(/\{\{\s*custom_message\s*\}\}/g, 'Thank you for your business!');
fullHtml += htmlContent;
fullHtml += '</body></html>';
// Create iframe for safe preview
previewDiv.innerHTML = '<iframe id="preview_iframe" style="width: 100%; height: 100%; border: none;"></iframe>';
const iframe = document.getElementById('preview_iframe');
if (iframe) {
try {
iframe.contentDocument.open();
iframe.contentDocument.write(fullHtml);
iframe.contentDocument.close();
} catch (e) {
console.error('Error writing to iframe:', e);
previewDiv.innerHTML = '<div class="text-red-500 p-4">Preview unavailable. Please check browser console.</div>';
}
}
}
// Close modal on outside click
document.addEventListener('click', function(event) {
const modal = document.getElementById('variablesModal');
if (event.target == modal) {
closeVariablesModal();
}
});
// Initialize when DOM and Toast UI Editor are ready
function waitForEditorAndInit() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
checkEditorAndInit();
});
} else {
checkEditorAndInit();
}
}
function checkEditorAndInit() {
console.log('checkEditorAndInit called');
console.log('window.toastui:', window.toastui);
console.log('document.readyState:', document.readyState);
// Check if Toast UI Editor is loaded
if (window.toastui && window.toastui.Editor) {
console.log('Toast UI Editor found, initializing...');
setTimeout(initEditor, 50);
} else {
console.log('Toast UI Editor not found, waiting...');
// Wait a bit and try again
setTimeout(function() {
if (window.toastui && window.toastui.Editor) {
console.log('Toast UI Editor found after wait, initializing...');
initEditor();
} else {
console.warn('Toast UI Editor not loaded, using fallback');
initEditor(); // Will use fallback
}
}, 500);
}
}
waitForEditorAndInit();
</script>
{% endblock %}

View File

@@ -390,6 +390,11 @@
<i class="fas fa-envelope w-4 mr-2"></i>{{ _('Email Configuration') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_email_templates') }}">
<i class="fas fa-envelope-open-text w-4 mr-2"></i>{{ _('Email Templates') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">
<i class="fas fa-file-pdf w-4 mr-2"></i>{{ _('PDF Layout') }}

View File

@@ -279,6 +279,64 @@
</div>
</div>
<!-- Email History Section -->
{% if email_history|length > 0 %}
<div class="mt-6 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-envelope mr-2"></i>Email History
</h2>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-border-light dark:border-border-dark">
<th class="p-2 text-sm font-medium">Sent At</th>
<th class="p-2 text-sm font-medium">Recipient</th>
<th class="p-2 text-sm font-medium">Status</th>
<th class="p-2 text-sm font-medium">Subject</th>
<th class="p-2 text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{% for email in email_history %}
<tr class="border-b border-border-light dark:border-border-dark">
<td class="p-2 text-sm">
{{ email.sent_at.strftime('%Y-%m-%d %H:%M:%S') if email.sent_at else 'N/A' }}
</td>
<td class="p-2 text-sm">{{ email.recipient_email }}</td>
<td class="p-2 text-sm">
<span class="px-2 py-1 rounded text-xs font-medium
{% if email.status == 'sent' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% elif email.status == 'opened' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif email.status == 'paid' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
{% elif email.status == 'failed' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
{% elif email.status == 'bounced' %}bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200
{% endif %}">
{{ email.status|title }}
</span>
</td>
<td class="p-2 text-sm">{{ email.subject }}</td>
<td class="p-2 text-sm">
<button onclick="resendInvoiceEmail({{ invoice.id }}, {{ email.id }}, '{{ email.recipient_email }}')"
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium">
<i class="fas fa-redo mr-1"></i>Resend
</button>
</td>
</tr>
{% if email.error_message %}
<tr class="bg-red-50 dark:bg-red-900/20">
<td colspan="5" class="p-2 text-sm text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1"></i>Error: {{ email.error_message }}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Send Email Modal -->
<div id="sendEmailModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
@@ -409,5 +467,95 @@ function hideDeleteModal() {
const modal = document.getElementById('deleteInvoiceModal');
if (modal) modal.classList.add('hidden');
}
function resendInvoiceEmail(invoiceId, emailId, recipientEmail) {
if (!confirm(`Resend invoice email to ${recipientEmail}?`)) {
return;
}
// Show the send email modal with pre-filled recipient
const modal = document.getElementById('sendEmailModal');
const recipientInput = document.getElementById('recipient_email');
if (modal && recipientInput) {
recipientInput.value = recipientEmail;
modal.classList.remove('hidden');
// Override form submission to use resend endpoint
const form = document.getElementById('sendEmailForm');
if (form) {
const originalOnSubmit = form.onsubmit;
form.onsubmit = function(event) {
event.preventDefault();
const formData = new FormData(form);
// Show loading state
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Resending...';
fetch(`/invoices/${invoiceId}/resend-email/${emailId}`, {
method: 'POST',
body: formData
})
.then(async response => {
const contentType = response.headers.get('content-type') || '';
const isJson = contentType.includes('application/json');
if (isJson) {
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
}
return data;
} else {
const text = await response.text();
if (!response.ok) {
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
}
return { success: false, error: text || 'Unexpected response format' };
}
})
.then(data => {
if (data.success) {
if (window.toastManager) {
window.toastManager.success('Invoice email resent successfully!');
} else {
alert('Invoice email resent successfully!');
}
hideSendEmailModal();
setTimeout(() => window.location.reload(), 1000);
} else {
const errorMsg = data.error || 'Failed to resend email';
console.error('Email resend error:', errorMsg);
if (window.toastManager) {
window.toastManager.error('Error: ' + errorMsg);
} else {
alert('Error: ' + errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
})
.catch(error => {
console.error('Email resend error:', error);
const errorMsg = error.message || 'Failed to resend email';
if (window.toastManager) {
window.toastManager.error('Error: ' + errorMsg);
} else {
alert('Error: ' + errorMsg);
}
submitBtn.disabled = false;
submitBtn.textContent = originalText;
});
// Restore original handler after use
setTimeout(() => {
form.onsubmit = originalOnSubmit;
}, 100);
};
}
}
}
</script>
{% endblock %}

466
tests/test_invoice_email.py Normal file
View File

@@ -0,0 +1,466 @@
"""
Tests for invoice email sending functionality
"""
import pytest
from unittest.mock import patch, MagicMock
from datetime import datetime, date, timedelta
from decimal import Decimal
from flask import current_app
from app import db
from app.models import Invoice, InvoiceEmail, User, Settings, Client, Project
from app.utils.email import send_invoice_email
from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, InvoiceItemFactory
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = UserFactory(username='testuser', role='user')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def test_client(app):
"""Create a test client"""
client = ClientFactory(name='Test Client', email='client@test.com')
db.session.commit()
return client
@pytest.fixture
def test_project(app, test_client):
"""Create a test project"""
project = ProjectFactory(
name='Test Project',
client_id=test_client.id,
billable=True,
hourly_rate=Decimal('100.00')
)
db.session.commit()
return project
@pytest.fixture
def test_invoice(app, test_user, test_project, test_client):
"""Create a test invoice with items"""
invoice = InvoiceFactory(
invoice_number='INV-2024-001',
project_id=test_project.id,
client_id=test_client.id,
client_name=test_client.name,
client_email=test_client.email,
due_date=date.today() + timedelta(days=30),
created_by=test_user.id,
status='draft',
subtotal=Decimal('1000.00'),
tax_rate=Decimal('20.00'),
tax_amount=Decimal('200.00'),
total_amount=Decimal('1200.00'),
currency_code='EUR'
)
db.session.commit()
# Add invoice item
item = InvoiceItemFactory(
invoice_id=invoice.id,
description='Test Service',
quantity=Decimal('10.00'),
unit_price=Decimal('100.00'),
total_amount=Decimal('1000.00')
)
db.session.commit()
return invoice
@pytest.fixture
def mock_pdf_generator():
"""Mock PDF generator"""
with patch('app.utils.email.InvoicePDFGenerator') as mock_gen:
mock_instance = MagicMock()
mock_instance.generate_pdf.return_value = b'fake_pdf_bytes'
mock_gen.return_value = mock_instance
yield mock_instance
@pytest.fixture
def mock_mail_send():
"""Mock mail.send"""
with patch('app.utils.email.mail.send') as mock_send:
yield mock_send
class TestSendInvoiceEmail:
"""Tests for send_invoice_email function"""
def test_send_invoice_email_success(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test successfully sending an invoice email"""
with app.app_context():
# Configure mail server
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is True
assert invoice_email is not None
assert invoice_email.recipient_email == 'client@test.com'
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.sent_by == test_user.id
assert invoice_email.status == 'sent'
assert 'successfully' in message.lower()
assert mock_mail_send.called
# Verify invoice status was updated
db.session.refresh(test_invoice)
assert test_invoice.status == 'sent'
def test_send_invoice_email_with_custom_message(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test sending invoice email with custom message"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
custom_message = "Thank you for your business!"
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user,
custom_message=custom_message
)
assert success is True
assert invoice_email is not None
# Verify the message was sent (check mail.send was called with message containing custom text)
assert mock_mail_send.called
def test_send_invoice_email_pdf_generation_failure(self, app, test_invoice, test_user, mock_mail_send):
"""Test handling PDF generation failure"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Mock PDF generator to fail
with patch('app.utils.email.InvoicePDFGenerator') as mock_gen:
mock_instance = MagicMock()
mock_instance.generate_pdf.side_effect = Exception("PDF generation failed")
mock_gen.return_value = mock_instance
# Mock fallback generator to also fail
with patch('app.utils.email.InvoicePDFGeneratorFallback') as mock_fallback:
mock_fallback_instance = MagicMock()
mock_fallback_instance.generate_pdf.side_effect = Exception("Fallback failed")
mock_fallback.return_value = mock_fallback_instance
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is False
assert invoice_email is None
assert 'pdf generation failed' in message.lower() or 'failed' in message.lower()
def test_send_invoice_email_no_mail_server(self, app, test_invoice, test_user, mock_pdf_generator):
"""Test sending email when mail server is not configured"""
with app.app_context():
current_app.config['MAIL_SERVER'] = None
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
# Should still attempt to send but may fail gracefully
# The function should handle this case
assert invoice_email is not None or success is False
def test_send_invoice_email_creates_tracking_record(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test that email tracking record is created"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Count existing records
initial_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is True
# Verify record was created
final_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
assert final_count == initial_count + 1
# Verify record details
assert invoice_email.recipient_email == 'client@test.com'
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.sent_by == test_user.id
def test_send_invoice_email_updates_draft_status(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test that draft invoice status is updated to 'sent'"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Ensure invoice is in draft status
test_invoice.status = 'draft'
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is True
# Verify status was updated
db.session.refresh(test_invoice)
assert test_invoice.status == 'sent'
def test_send_invoice_email_does_not_update_non_draft_status(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test that non-draft invoice status is not changed"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Set invoice to 'sent' status
test_invoice.status = 'sent'
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is True
# Verify status remained 'sent'
db.session.refresh(test_invoice)
assert test_invoice.status == 'sent'
def test_send_invoice_email_with_email_template(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
"""Test sending invoice email with custom email template"""
with app.app_context():
from app.models import InvoiceTemplate
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Create an email template
template = InvoiceTemplate(
name='Test Template',
html='<html><body><h1>Invoice {{ invoice.invoice_number }}</h1></body></html>',
css='body { color: black; }'
)
db.session.add(template)
db.session.commit()
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user,
email_template_id=template.id
)
assert success is True
assert invoice_email is not None
assert mock_mail_send.called
def test_send_invoice_email_failure_creates_failed_record(self, app, test_invoice, test_user, mock_pdf_generator):
"""Test that failed email sends create a failed tracking record"""
with app.app_context():
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
# Mock mail.send to raise an exception
with patch('app.utils.email.mail.send') as mock_send:
mock_send.side_effect = Exception("SMTP connection failed")
success, invoice_email, message = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
assert success is False
# Should create a failed record
failed_record = InvoiceEmail.query.filter_by(
invoice_id=test_invoice.id,
status='failed'
).first()
assert failed_record is not None
assert failed_record.error_message is not None
class TestInvoiceEmailRoutes:
"""Tests for invoice email routes"""
def test_send_invoice_email_route_success(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
"""Test the send invoice email route"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
sess['_fresh'] = True
response = client.post(
f'/invoices/{test_invoice.id}/send-email',
data={
'recipient_email': 'client@test.com',
'csrf_token': 'test_token'
}
)
# Should return success (may need to handle CSRF token properly in test)
assert response.status_code in [200, 400, 403] # 400/403 if CSRF fails
def test_get_invoice_email_history(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
"""Test getting invoice email history"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
sess['_fresh'] = True
# First send an email
with client.application.app_context():
from app.utils.email import send_invoice_email
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
# Then get history
response = client.get(f'/invoices/{test_invoice.id}/email-history')
# Should return success (may need to handle authentication properly)
assert response.status_code in [200, 401, 403]
def test_resend_invoice_email_route(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
"""Test the resend invoice email route"""
with client.session_transaction() as sess:
sess['_user_id'] = str(test_user.id)
sess['_fresh'] = True
# First send an email to create a record
with client.application.app_context():
from app.utils.email import send_invoice_email
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
success, invoice_email, _ = send_invoice_email(
invoice=test_invoice,
recipient_email='client@test.com',
sender_user=test_user
)
if success and invoice_email:
# Then resend it
response = client.post(
f'/invoices/{test_invoice.id}/resend-email/{invoice_email.id}',
data={
'recipient_email': 'client@test.com',
'csrf_token': 'test_token'
}
)
# Should return success (may need to handle CSRF token properly)
assert response.status_code in [200, 400, 403]
class TestInvoiceEmailModel:
"""Tests for InvoiceEmail model"""
def test_invoice_email_creation(self, app, test_invoice, test_user):
"""Test creating an InvoiceEmail record"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email='client@test.com',
subject='Test Invoice',
sent_by=test_user.id
)
db.session.add(invoice_email)
db.session.commit()
assert invoice_email.id is not None
assert invoice_email.invoice_id == test_invoice.id
assert invoice_email.recipient_email == 'client@test.com'
assert invoice_email.status == 'sent'
assert invoice_email.sent_at is not None
def test_invoice_email_mark_opened(self, app, test_invoice, test_user):
"""Test marking email as opened"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email='client@test.com',
subject='Test Invoice',
sent_by=test_user.id
)
db.session.add(invoice_email)
db.session.commit()
invoice_email.mark_opened()
db.session.commit()
assert invoice_email.status == 'opened'
assert invoice_email.opened_at is not None
assert invoice_email.opened_count == 1
def test_invoice_email_mark_failed(self, app, test_invoice, test_user):
"""Test marking email as failed"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email='client@test.com',
subject='Test Invoice',
sent_by=test_user.id
)
db.session.add(invoice_email)
db.session.commit()
error_message = "SMTP connection failed"
invoice_email.mark_failed(error_message)
db.session.commit()
assert invoice_email.status == 'failed'
assert invoice_email.error_message == error_message
def test_invoice_email_to_dict(self, app, test_invoice, test_user):
"""Test converting InvoiceEmail to dictionary"""
with app.app_context():
invoice_email = InvoiceEmail(
invoice_id=test_invoice.id,
recipient_email='client@test.com',
subject='Test Invoice',
sent_by=test_user.id
)
db.session.add(invoice_email)
db.session.commit()
email_dict = invoice_email.to_dict()
assert isinstance(email_dict, dict)
assert email_dict['invoice_id'] == test_invoice.id
assert email_dict['recipient_email'] == 'client@test.com'
assert email_dict['subject'] == 'Test Invoice'
assert email_dict['status'] == 'sent'
assert 'sent_at' in email_dict
assert 'created_at' in email_dict