diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6897de3..ce96135 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,5 +105,5 @@ jobs: file: Dockerfile push: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && (env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '') }} tags: | - ${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:latest - ${{ env.REGISTRY_URL }}/allucanget/contact.allucanget.biz:${{ steps.meta.outputs.sha }} + ${{ env.REGISTRY_URL }}/allucanget/contact-server:latest + ${{ env.REGISTRY_URL }}/allucanget/contact-server:${{ steps.meta.outputs.sha }} diff --git a/README.md b/README.md index 30f4143..ffba7f3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,34 @@ # Server README -Backend service for the contact website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration. +Backend service for a static website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration. + +## Table of Contents + +- [Server README](#server-readme) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [Architecture](#architecture) + - [Quick Start](#quick-start) + - [Admin Access](#admin-access) + - [API Surface](#api-surface) + - [Embeddable Forms](#embeddable-forms) + - [Running With Docker](#running-with-docker) + - [Build manually](#build-manually) + - [Run with explicit environment variables](#run-with-explicit-environment-variables) + - [Run using docker-compose](#run-using-docker-compose) + - [Environment Variables](#environment-variables) + - [Core runtime](#core-runtime) + - [Admin authentication](#admin-authentication) + - [Database configuration](#database-configuration) + - [Email delivery](#email-delivery) + - [Rate limiting and caching](#rate-limiting-and-caching) + - [Request hardening](#request-hardening) + - [Observability](#observability) + - [Docker / Gunicorn runtime](#docker--gunicorn-runtime) + - [Health Checks and Monitoring](#health-checks-and-monitoring) + - [Testing](#testing) + - [Deployment Notes](#deployment-notes) + - [Email Templates](#email-templates) ## Overview @@ -119,7 +147,7 @@ Replace `https://your-server-domain` with your actual server URL. The iframe cod ### Build manually ```pwsh -docker build -t contact.allucanget.biz -f Dockerfile . +docker build -t backend-server -f Dockerfile . ``` ### Run with explicit environment variables @@ -132,7 +160,7 @@ docker run --rm -p 5002:5002 ` -e SMTP_USERNAME=api@example.com ` -e SMTP_PASSWORD=secret ` -e SMTP_RECIPIENTS=hello@example.com ` - contact.allucanget.biz + backend-server ``` ### Run using docker-compose diff --git a/server/routes/__init__.py b/server/routes/__init__.py index 9649e7f..6a5a7fe 100644 --- a/server/routes/__init__.py +++ b/server/routes/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from flask import Flask -from . import admin, auth, contact, embed, monitoring, newsletter +from . import admin, auth, contact, embed, monitoring, newsletter, static def register_blueprints(app: Flask) -> None: @@ -14,3 +14,4 @@ def register_blueprints(app: Flask) -> None: app.register_blueprint(auth.bp) app.register_blueprint(admin.bp) app.register_blueprint(embed.bp) + app.register_blueprint(static.bp) diff --git a/server/routes/admin.py b/server/routes/admin.py index 0c82225..87c1f20 100644 --- a/server/routes/admin.py +++ b/server/routes/admin.py @@ -14,21 +14,21 @@ bp = Blueprint("admin", __name__, url_prefix="/admin") @auth.login_required def dashboard(): """Display admin dashboard overview.""" - return render_template("admin_dashboard.html") + return render_template("admin/admin_dashboard.html") @bp.route("/newsletter") @auth.login_required def newsletter_subscribers(): """Display newsletter subscriber management page.""" - return render_template("admin_newsletter.html") + return render_template("admin/admin_newsletter.html") @bp.route("/newsletter/create") @auth.login_required def newsletter_create(): """Display newsletter creation and sending page.""" - return render_template("admin_newsletter_create.html") + return render_template("admin/admin_newsletter_create.html") @bp.route("/settings") @@ -72,21 +72,28 @@ def settings_page(): }, } - return render_template("admin_settings.html", settings=app_settings) + return render_template("admin/admin_settings.html", settings=app_settings) @bp.route("/submissions") @auth.login_required def submissions(): """Display contact form submissions page.""" - return render_template("admin_submissions.html") + return render_template("admin/admin_submissions.html") @bp.route("/embeds") @auth.login_required def embeds(): """Display embeddable forms management page.""" - return render_template("admin_embeds.html") + return render_template("admin/admin_embeds.html") + + +@bp.route('/email-templates') +@auth.login_required +def email_templates(): + """Display admin page for editing email templates.""" + return render_template('admin/admin_email_templates.html') @bp.route("/api/settings", methods=["GET"]) @@ -110,6 +117,11 @@ def validate_setting(key: str, value: str) -> str | None: "newsletter_enabled": lambda v: v in ["true", "false"], "rate_limit_max": lambda v: v.isdigit() and 0 <= int(v) <= 1000, "rate_limit_window": lambda v: v.isdigit() and 1 <= int(v) <= 3600, + # Embed dimensions (pixels) + "embed_contact_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000, + "embed_contact_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000, + "embed_newsletter_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000, + "embed_newsletter_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000, } if key in validations and not validations[key](value): diff --git a/server/routes/embed.py b/server/routes/embed.py index 0adad82..48ec2a0 100644 --- a/server/routes/embed.py +++ b/server/routes/embed.py @@ -1,8 +1,7 @@ """Embeddable forms routes.""" from __future__ import annotations -from flask import Blueprint, render_template, send_from_directory -import os +from flask import Blueprint, render_template bp = Blueprint("embed", __name__) @@ -17,10 +16,3 @@ def contact_form(): def newsletter_form(): """Serve the embeddable newsletter subscription form.""" return render_template("embed_newsletter.html") - - -@bp.route("/static/css/styles.css", methods=["GET"]) -def serve_css(): - """Serve the unified CSS file.""" - static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static') - return send_from_directory(static_dir, 'css/styles.css') diff --git a/server/routes/static.py b/server/routes/static.py new file mode 100644 index 0000000..75a49bc --- /dev/null +++ b/server/routes/static.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from flask import Blueprint, render_template, send_from_directory +import os + +bp = Blueprint("static", __name__) + + +@bp.route("/static/css/styles.css", methods=["GET"]) +def serve_css(): + """Serve the unified CSS file.""" + static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static') + return send_from_directory(static_dir, 'css/styles.css') + + +@bp.route("/static/css/admin.css", methods=["GET"]) +def serve_admin_css(): + """Serve the unified CSS file.""" + static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static') + return send_from_directory(static_dir, 'css/admin.css') + + +@bp.route("/static/js/admin.js", methods=["GET"]) +def serve_admin_js(): + """Serve the unified JS file.""" + static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static') + return send_from_directory(static_dir, 'js/admin.js') diff --git a/static/css/admin.css b/static/css/admin.css new file mode 100644 index 0000000..e0379a0 --- /dev/null +++ b/static/css/admin.css @@ -0,0 +1,231 @@ +/* Navigation links */ +nav a { + color: #007bff; + text-decoration: none; + margin-right: 20px; +} +nav a:hover { + text-decoration: underline; +} +.nav { + margin-bottom: 20px; + padding: 10px; + background-color: #f8f9fa; + border-radius: 5px; + text-align: center; +} +.nav a { + margin-right: 15px; + color: #007bff; + text-decoration: none; +} +.nav a:hover { + text-decoration: underline; +} + +/* Admin newsletter */ +.filters { + margin-bottom: 20px; + padding: 15px; + background-color: #f8f9fa; + border-radius: 5px; + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; +} +.filters form { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: end; +} +.filters input, +.filters select { + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + min-width: 150px; +} +.filters button { + padding: 8px 16px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} +.filters button:hover { + background-color: #0056b3; +} + +/* Subscribers table */ +.subscribers-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; +} +.subscribers-table th, +.subscribers-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid #ddd; +} +.subscribers-table th { + background-color: #f8f9fa; + font-weight: bold; +} +.subscribers-table tr:hover { + background-color: #f5f5f5; +} + +.loading { + text-align: center; + padding: 20px; + color: #666; +} + +.actions { + display: flex; + gap: 5px; +} + +/* Newsletter creation form */ + +.form-section { + margin-bottom: 30px; + padding: 20px; + border: 1px solid #ddd; + border-radius: 8px; + background-color: #f9f9f9; +} +.form-section h2 { + margin-top: 0; + color: #555; + border-bottom: 1px solid #ddd; + padding-bottom: 10px; +} +.form-group { + margin-bottom: 15px; +} +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: bold; + color: #333; +} +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} +.form-group textarea { + min-height: 200px; + resize: vertical; +} +.form-row { + display: flex; + gap: 15px; + margin-bottom: 15px; +} +.form-row .form-group { + flex: 1; + margin-bottom: 0; +} +.newsletter-preview { + margin-top: 20px; + padding: 20px; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; +} +.newsletter-preview h3 { + margin-top: 0; + color: #555; +} +.newsletter-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 20px; +} +.hidden { + display: none; +} + +/* Admin settings */ +.setting strong { + display: inline-block; + width: 200px; +} +.iframe-code { + width: 100%; + height: 100px; + font-family: monospace; + padding: 10px; + border: 1px solid #ccc; + border-radius: 3px; +} + +/* Admin settings cards grid */ +.settings-cards { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 8px 0 16px 0; +} + +.settings-card { + background: #ffffff; + border: 1px solid #e6e6e6; + border-radius: 8px; + padding: 12px 14px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + flex: 1 1 300px; + min-width: 220px; +} + +.card-title { + margin: 0 0 8px 0; + font-size: 1.05rem; +} + +.card-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Keep individual dynamic setting layout readable */ +.setting { + font-size: 0.95rem; + color: #222; +} + +@media (max-width: 720px) { + .settings-card { + flex: 1 1 100%; + } +} + +/* Spinner styles used by embed pages */ +#spinnerIcon { + animation: spin 1s linear infinite; +} +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +#submitSpinner { + display: flex; + align-items: center; +} diff --git a/static/css/styles.css b/static/css/styles.css index 3e89e9d..44c953c 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -3,7 +3,9 @@ /* Base styles */ body { font-family: Arial, sans-serif; - margin: 20px; + max-width: 1200px; + margin: 0 auto; + padding: 20px; background-color: #f9f9f9; color: #333; } @@ -76,21 +78,32 @@ button:hover, .btn:hover { background-color: #0056b3; } - .btn-secondary { background-color: #6c757d; } - .btn-secondary:hover { background-color: #545b62; } - .btn-danger { background-color: #dc3545; } - .btn-danger:hover { - background-color: #c82333; + background-color: #dc3545; + color: white; +} +.btn-primary { + background-color: #007bff; + color: white; +} +.btn-primary:hover { + background-color: #0056b3; +} +.btn-success { + background-color: #28a745; + color: white; +} +.btn-success:hover { + background-color: #1e7e34; } /* Message styles */ @@ -164,13 +177,21 @@ button:hover, min-width: 150px; margin: 5px; } - .stat-card h3 { margin: 0; color: #495057; font-size: 2em; } - +.stat-card h4 { + margin: 0 0 10px 0; + color: #666; + font-size: 14px; +} +.stat-card .number { + font-size: 24px; + font-weight: bold; + color: #333; +} .stat-card p { margin: 5px 0 0 0; color: #6c757d; @@ -260,17 +281,6 @@ button:hover, border-radius: 8px; } -/* Navigation links */ -nav a { - color: #007bff; - text-decoration: none; - margin-right: 20px; -} - -nav a:hover { - text-decoration: underline; -} - /* Table styles */ table { width: 100%; @@ -298,6 +308,7 @@ tr:hover { .pagination { display: flex; justify-content: center; + align-items: center; gap: 10px; margin-top: 20px; } @@ -321,6 +332,26 @@ tr:hover { color: white; } +/* Pagination */ +.pagination button { + padding: 8px 12px; + border: 1px solid #ddd; + background-color: white; + cursor: pointer; + border-radius: 4px; +} +.pagination button:hover { + background-color: #f8f9fa; +} +.pagination button:disabled { + background-color: #e9ecef; + cursor: not-allowed; +} +.pagination .current-page { + font-weight: bold; + color: #007bff; +} + /* Utility classes */ .text-center { text-align: center; diff --git a/static/js/admin.js b/static/js/admin.js new file mode 100644 index 0000000..7d99a36 --- /dev/null +++ b/static/js/admin.js @@ -0,0 +1,986 @@ +/** + * Admin JavaScript - Consolidated functionality for all admin pages + * Provides shared utilities, API interactions, and page-specific features + */ + +// ==================== UTILITY FUNCTIONS ==================== + +/** + * Displays a message to the user with auto-hide functionality + * @param {string} text - The message text to display + * @param {string} type - Message type ('success', 'error', 'info', etc.) + */ +function showMessage(text, type) { + let messageDiv = document.getElementById("message"); + if (!messageDiv) { + messageDiv = document.createElement("div"); + messageDiv.id = "message"; + document.body.insertBefore(messageDiv, document.body.firstChild); + } + messageDiv.className = `message ${type}`; + messageDiv.textContent = text; + messageDiv.style.display = "block"; + setTimeout(() => (messageDiv.style.display = "none"), 5000); +} + +/** + * Escapes HTML characters to prevent XSS + * @param {string} text - Text to escape + * @returns {string} Escaped HTML string + */ +const escapeHtml = (text) => { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +}; + +/** + * Copies text to clipboard with fallback for older browsers + * @param {string} text - Text to copy + * @returns {Promise} Success status + */ +async function copyToClipboard(text) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + // Fall through to fallback + } + } + + // Fallback method + const tmp = document.createElement("textarea"); + tmp.value = text; + tmp.setAttribute("readonly", ""); + tmp.style.position = "absolute"; + tmp.style.left = "-9999px"; + document.body.appendChild(tmp); + tmp.select(); + + try { + const ok = document.execCommand("copy"); + document.body.removeChild(tmp); + return ok; + } catch (err) { + document.body.removeChild(tmp); + return false; + } +} + +// ==================== SETTINGS MANAGEMENT ==================== + +/** + * Loads settings from API and displays them + */ +function loadSettingsForList() { + fetch("/admin/api/settings") + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + appSettings = data.settings || {}; + displaySettings(); + } else { + showMessage( + "Error loading settings: " + (data.message || "Unknown error"), + "error" + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showMessage("Error loading settings", "error"); + }); +} + +/** + * Fetches embed settings from API + * @returns {Promise} Settings object or empty object on error + */ +async function fetchEmbedSettings() { + try { + const response = await fetch("/admin/api/settings"); + const data = await response.json(); + return data.status === "ok" && data.settings ? data.settings : {}; + } catch (err) { + console.error("Failed to load embed settings", err); + return {}; + } +} + +/** + * Saves a setting via API + * @param {string} key - Setting key + * @param {string} inputId - Input element ID + */ +async function saveEmbedSetting(key, inputId) { + const input = document.getElementById(inputId); + if (!input) return showMessage("Input not found", "error"); + + const value = (input.value || "").toString().trim(); + if (!value) return showMessage("Value cannot be empty", "error"); + + try { + const response = await fetch( + `/admin/api/settings/${encodeURIComponent(key)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + } + ); + + const data = await response.json(); + if (data.status === "ok") { + showMessage("Setting saved", "success"); + // Rebuild textarea values to reflect the new size + if (typeof loadEmbedSettingsAndInit === "function") { + loadEmbedSettingsAndInit(); + } + } else { + showMessage("Failed to save setting: " + (data.message || ""), "error"); + } + } catch (err) { + console.error("Failed to save setting", err); + showMessage("Failed to save setting", "error"); + } +} + +// ==================== EMBED MANAGEMENT ==================== + +/** + * Initializes embed settings and populates form fields + */ +async function loadEmbedSettingsAndInit() { + const origin = + window.location && window.location.origin + ? window.location.origin + : "http://your-server-domain"; + + // Default dimensions + let contactWidth = "600", + contactHeight = "400"; + let newsletterWidth = "600", + newsletterHeight = "300"; + + try { + const settings = await fetchEmbedSettings(); + contactWidth = settings.embed_contact_width || contactWidth; + contactHeight = settings.embed_contact_height || contactHeight; + newsletterWidth = settings.embed_newsletter_width || newsletterWidth; + newsletterHeight = settings.embed_newsletter_height || newsletterHeight; + } catch (err) { + console.error("Error loading embed settings", err); + } + + // Update input fields + const inputs = { + contactWidth, + contactHeight, + newsletterWidth, + newsletterHeight, + }; + + Object.entries(inputs).forEach(([id, value]) => { + const element = document.getElementById(id); + if (element) element.value = value; + }); + + // Update iframe code textareas + const contactTextarea = document.getElementById("iframeCode"); + if (contactTextarea) { + contactTextarea.value = ``; + } + + const newsletterTextarea = document.getElementById("iframeNewsletterCode"); + if (newsletterTextarea) { + newsletterTextarea.value = ``; + } + + const contactPreview = document.getElementById("contactFormPreview"); + if (contactPreview) { + contactPreview.innerHTML = ``; + } + + const newsletterPreview = document.getElementById("newsletterFormPreview"); + if (newsletterPreview) { + newsletterPreview.innerHTML = ``; + } +} + +/** + * Copies contact iframe code to clipboard + */ +function copyIframeCode() { + const textarea = document.getElementById("iframeCode"); + if (!textarea) return showMessage("Contact iframe not found", "error"); + + copyToClipboard(textarea.value).then((ok) => + showMessage( + ok + ? "Contact iframe code copied to clipboard!" + : "Failed to copy contact iframe code", + ok ? "success" : "error" + ) + ); +} + +/** + * Copies newsletter iframe code to clipboard + */ +function copyNewsletterIframeCode() { + const textarea = document.getElementById("iframeNewsletterCode"); + if (!textarea) return showMessage("Newsletter iframe not found", "error"); + + copyToClipboard(textarea.value).then((ok) => + showMessage( + ok + ? "Newsletter iframe code copied to clipboard!" + : "Failed to copy newsletter iframe code", + ok ? "success" : "error" + ) + ); +} + +// ==================== DASHBOARD ==================== + +/** + * Loads and displays dashboard statistics + */ +async function loadDashboardStats() { + try { + const [contactRes, newsletterRes, settingsRes] = await Promise.all([ + fetch("/admin/api/contact?page=1&per_page=1"), + fetch("/admin/api/newsletter?page=1&per_page=1"), + fetch("/admin/api/settings"), + ]); + + if (contactRes.ok) { + const data = await contactRes.json(); + document.getElementById("contact-count").textContent = + data.pagination.total; + } + + if (newsletterRes.ok) { + const data = await newsletterRes.json(); + document.getElementById("newsletter-count").textContent = + data.pagination.total; + } + + if (settingsRes.ok) { + const data = await settingsRes.json(); + document.getElementById("settings-count").textContent = Object.keys( + data.settings || {} + ).length; + } + } catch (error) { + console.error("Failed to load dashboard stats:", error); + } +} + +// ==================== EMAIL TEMPLATES ==================== + +/** + * Loads email template from settings + */ +function loadEmailTemplate() { + const textarea = document.getElementById("newsletterTemplate"); + if (!textarea) return; + + fetch("/admin/api/settings") + .then((r) => r.json()) + .then((data) => { + if ( + data.status === "ok" && + data.settings && + data.settings.newsletter_confirmation_template + ) { + textarea.value = data.settings.newsletter_confirmation_template; + } + }) + .catch((err) => console.error("Failed to load template", err)); +} + +/** + * Saves email template to settings + */ +function saveEmailTemplate() { + const textarea = document.getElementById("newsletterTemplate"); + const message = document.getElementById("message"); + if (!textarea || !message) return; + + const value = textarea.value || ""; + + fetch("/admin/api/settings/newsletter_confirmation_template", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value }), + }) + .then((r) => r.json()) + .then((data) => { + const isSuccess = data.status === "ok"; + message.className = `message ${isSuccess ? "success" : "error"}`; + message.textContent = isSuccess + ? "Template saved" + : `Failed to save template: ${data.message || ""}`; + setTimeout(() => (message.textContent = ""), 4000); + }) + .catch((err) => { + console.error("Failed to save template", err); + message.className = "message error"; + message.textContent = "Failed to save template"; + }); +} + +// ==================== NEWSLETTER CREATION ==================== + +let newsletterStats = {}; + +/** + * Loads newsletter statistics + */ +function loadNewsletterStats() { + fetch("/admin/api/newsletter?page=1&per_page=1") + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + const total = data.pagination.total; + document.getElementById("totalSubscribers").textContent = total; + document.getElementById("activeSubscribers").textContent = total; // Assume all active + newsletterStats.totalSubscribers = total; + } + }) + .catch((error) => console.error("Error loading subscriber stats:", error)) + .finally(() => { + const lastSentEl = document.getElementById("lastSent"); + if (lastSentEl) lastSentEl.textContent = "N/A"; + }); +} + +/** + * Generates newsletter preview + */ +function previewNewsletter() { + const subject = document.getElementById("subject").value.trim(); + const content = document.getElementById("content").value.trim(); + const senderName = document.getElementById("senderName").value.trim(); + + if (!subject || !content) { + return showMessage( + "Subject and content are required for preview.", + "error" + ); + } + + const previewContent = document.getElementById("previewContent"); + if (previewContent) { + previewContent.innerHTML = ` +

${escapeHtml(subject)}

+ ${ + senderName + ? `

From: ${escapeHtml(senderName)}

` + : "" + } +
+ ${content.replace(/\n/g, "
")} +
+ `; + } + + const previewSection = document.getElementById("previewSection"); + if (previewSection) previewSection.classList.remove("hidden"); + + showMessage("Newsletter preview generated.", "info"); +} + +/** + * Saves newsletter as draft + */ +function saveDraft() { + const form = document.getElementById("newsletterForm"); + if (!form) return; + + const formData = new FormData(form); + const newsletterData = { + subject: formData.get("subject"), + content: formData.get("content"), + sender_name: formData.get("sender_name"), + send_date: formData.get("send_date"), + status: "draft", + }; + + if (!newsletterData.subject || !newsletterData.content) { + return showMessage("Subject and content are required.", "error"); + } + + fetch("/admin/api/newsletters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newsletterData), + }) + .then((response) => response.json()) + .then((data) => { + showMessage( + data.status === "ok" + ? "Newsletter draft saved successfully!" + : data.message || "Failed to save draft.", + data.status === "ok" ? "success" : "error" + ); + }) + .catch((error) => { + console.error("Error saving draft:", error); + showMessage("Failed to save draft.", "error"); + }); +} + +/** + * Sends newsletter to subscribers + */ +function sendNewsletter() { + const form = document.getElementById("newsletterForm"); + if (!form) return; + + const formData = new FormData(form); + const newsletterData = { + subject: formData.get("subject"), + content: formData.get("content"), + sender_name: formData.get("sender_name"), + send_date: formData.get("send_date"), + status: "sent", + }; + + if (!newsletterData.subject || !newsletterData.content) { + return showMessage("Subject and content are required.", "error"); + } + + if ( + !confirm( + `Are you sure you want to send this newsletter to ${ + newsletterStats.totalSubscribers || 0 + } subscribers?` + ) + ) { + return; + } + + // Save and send + fetch("/admin/api/newsletters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newsletterData), + }) + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + const newsletterId = data.newsletter_id; + return fetch(`/admin/api/newsletters/${newsletterId}/send`, { + method: "POST", + }); + } else { + throw new Error(data.message || "Failed to save newsletter."); + } + }) + .then((response) => response.json()) + .then((data) => { + showMessage( + data.status === "ok" + ? `Newsletter sent successfully to ${data.sent_count} subscribers!` + : data.message || "Failed to send newsletter.", + data.status === "ok" ? "success" : "error" + ); + }) + .catch((error) => { + console.error("Error sending newsletter:", error); + showMessage("Failed to send newsletter.", "error"); + }); +} + +/** + * Clears newsletter form with confirmation + */ +function clearForm() { + if ( + !confirm( + "Are you sure you want to clear the form? All unsaved changes will be lost." + ) + ) { + return; + } + + const form = document.getElementById("newsletterForm"); + if (form) form.reset(); + + const previewSection = document.getElementById("previewSection"); + if (previewSection) previewSection.classList.add("hidden"); + + showMessage("Form cleared.", "info"); +} + +// ==================== NEWSLETTER SUBSCRIBERS ==================== + +let currentPage = 1; +let currentFilters = { + email: "", + sort_by: "subscribed_at", + sort_order: "desc", +}; + +/** + * Applies filters to subscriber list + */ +function applyFilters() { + const emailFilter = document.getElementById("emailFilter"); + const sortBy = document.getElementById("sortBy"); + const sortOrder = document.getElementById("sortOrder"); + + if (emailFilter) currentFilters.email = emailFilter.value.trim(); + if (sortBy) currentFilters.sort_by = sortBy.value; + if (sortOrder) currentFilters.sort_order = sortOrder.value; + + currentPage = 1; + loadSubscribers(); +} + +/** + * Clears all filters + */ +function clearFilters() { + const emailFilter = document.getElementById("emailFilter"); + const sortBy = document.getElementById("sortBy"); + const sortOrder = document.getElementById("sortOrder"); + + if (emailFilter) emailFilter.value = ""; + if (sortBy) sortBy.value = "subscribed_at"; + if (sortOrder) sortOrder.value = "desc"; + + currentFilters = { email: "", sort_by: "subscribed_at", sort_order: "desc" }; + currentPage = 1; + loadSubscribers(); +} + +/** + * Loads subscribers with current filters and pagination + */ +function loadSubscribers() { + const loading = document.getElementById("loading"); + const table = document.getElementById("subscribersTable"); + const pagination = document.getElementById("pagination"); + + if (loading) loading.style.display = "block"; + if (table) table.style.display = "none"; + if (pagination) pagination.style.display = "none"; + + const params = new URLSearchParams({ + page: currentPage, + per_page: 50, + sort_by: currentFilters.sort_by, + sort_order: currentFilters.sort_order, + }); + + if (currentFilters.email) params.append("email", currentFilters.email); + + fetch(`/admin/api/newsletter?${params}`) + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + displaySubscribers(data.subscribers); + updatePagination(data.pagination); + } else { + showMessage( + "Error loading subscribers: " + (data.message || "Unknown error"), + "error" + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showMessage("Error loading subscribers", "error"); + }) + .finally(() => { + if (loading) loading.style.display = "none"; + }); +} + +/** + * Displays subscribers in table + * @param {Array} subscribers - Array of subscriber objects + */ +function displaySubscribers(subscribers) { + const tbody = document.getElementById("subscribersBody"); + if (!tbody) return; + + tbody.innerHTML = ""; + + if (subscribers.length === 0) { + tbody.innerHTML = + 'No subscribers found'; + } else { + subscribers.forEach((subscriber) => { + const row = document.createElement("tr"); + row.innerHTML = ` + ${escapeHtml(subscriber.email)} + ${new Date(subscriber.subscribed_at).toLocaleDateString()} + + + + `; + tbody.appendChild(row); + }); + } + + const table = document.getElementById("subscribersTable"); + if (table) table.style.display = "table"; +} + +/** + * Updates pagination controls + * @param {Object} pagination - Pagination data + */ +function updatePagination(pagination) { + const pageInfo = document.getElementById("pageInfo"); + const prevBtn = document.getElementById("prevBtn"); + const nextBtn = document.getElementById("nextBtn"); + + if (pageInfo) + pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`; + if (prevBtn) prevBtn.disabled = pagination.page <= 1; + if (nextBtn) nextBtn.disabled = pagination.page >= pagination.pages; + + const paginationDiv = document.getElementById("pagination"); + if (paginationDiv) paginationDiv.style.display = "flex"; +} + +/** + * Changes to a different page + * @param {number} page - Page number to navigate to + */ +function changePage(page) { + currentPage = page; + loadSubscribers(); +} + +/** + * Unsubscribes a user from newsletter + * @param {string} email - Email address to unsubscribe + */ +function unsubscribe(email) { + if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) return; + + fetch("/api/newsletter", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + showMessage("Subscriber unsubscribed successfully", "success"); + loadSubscribers(); + } else { + showMessage( + "Error unsubscribing: " + (data.message || "Unknown error"), + "error" + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showMessage("Error unsubscribing subscriber", "error"); + }); +} + +// ==================== CONTACT SUBMISSIONS ==================== + +let submissionsCurrentPage = 1; +let submissionsSortBy = "created_at"; +let submissionsSortOrder = "desc"; + +/** + * Clears contact submission filters + */ +function clearSubmissionFilters() { + const email = document.getElementById("email"); + const dateFrom = document.getElementById("date_from"); + const dateTo = document.getElementById("date_to"); + const perPage = document.getElementById("per_page"); + + if (email) email.value = ""; + if (dateFrom) dateFrom.value = ""; + if (dateTo) dateTo.value = ""; + if (perPage) perPage.value = "50"; + + submissionsCurrentPage = 1; + submissionsSortBy = "created_at"; + submissionsSortOrder = "desc"; + loadSubmissions(); +} + +/** + * Loads contact submissions with filters + */ +function loadSubmissions() { + const loading = document.getElementById("loading"); + const table = document.getElementById("submissionsTable"); + + if (loading) loading.style.display = "block"; + if (table) table.style.opacity = "0.5"; + + const perPage = document.getElementById("per_page"); + const email = document.getElementById("email"); + const dateFrom = document.getElementById("date_from"); + const dateTo = document.getElementById("date_to"); + + const params = new URLSearchParams({ + page: submissionsCurrentPage, + per_page: perPage?.value || "50", + sort_by: submissionsSortBy, + sort_order: submissionsSortOrder, + email: email?.value || "", + date_from: dateFrom?.value || "", + date_to: dateTo?.value || "", + }); + + fetch(`/api/contact?${params}`) + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + displaySubmissions(data.submissions); + displaySubmissionPagination(data.pagination); + } else { + showMessage( + "Error loading submissions: " + (data.message || "Unknown error"), + "error" + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showMessage("Error loading submissions", "error"); + }) + .finally(() => { + if (loading) loading.style.display = "none"; + if (table) table.style.opacity = "1"; + }); +} + +/** + * Displays contact submissions in table + * @param {Array} submissions - Array of submission objects + */ +function displaySubmissions(submissions) { + const tbody = document.getElementById("submissionsBody"); + if (!tbody) return; + + if (submissions.length === 0) { + tbody.innerHTML = + 'No submissions found'; + return; + } + + tbody.innerHTML = submissions + .map( + (submission) => ` + + ${submission.id} + ${escapeHtml(submission.name)} + ${escapeHtml(submission.email)} + ${escapeHtml(submission.company || "")} + ${escapeHtml(submission.message)} + ${new Date(submission.created_at).toLocaleString()} + + + ` + ) + .join(""); +} + +/** + * Updates submission pagination controls + * @param {Object} pagination - Pagination data + */ +function displaySubmissionPagination(pagination) { + const paginationDiv = document.getElementById("pagination"); + if (!paginationDiv) return; + + if (pagination.pages <= 1) { + paginationDiv.innerHTML = ""; + return; + } + + let buttons = []; + + // Previous button + buttons.push( + `` + ); + + // Page numbers + const startPage = Math.max(1, pagination.page - 2); + const endPage = Math.min(pagination.pages, pagination.page + 2); + + for (let i = startPage; i <= endPage; i++) { + buttons.push( + `` + ); + } + + // Next button + buttons.push( + `` + ); + + paginationDiv.innerHTML = buttons.join(""); +} + +/** + * Changes to a different submission page + * @param {number} page - Page number to navigate to + */ +function changeSubmissionPage(page) { + submissionsCurrentPage = page; + loadSubmissions(); + window.scrollTo(0, 0); +} + +/** + * Deletes a contact submission + * @param {number} id - Submission ID to delete + */ +function deleteSubmission(id) { + if (!confirm("Are you sure you want to delete this submission?")) return; + + fetch(`/api/contact/${id}`, { method: "DELETE" }) + .then((response) => response.json()) + .then((data) => { + if (data.status === "ok") { + showMessage("Submission deleted successfully", "success"); + loadSubmissions(); + } else { + showMessage( + "Error deleting submission: " + (data.message || "Unknown error"), + "error" + ); + } + }) + .catch((error) => { + console.error("Error:", error); + showMessage("Error deleting submission", "error"); + }); +} + +// ==================== INITIALIZATION ==================== + +// Global admin object for external access +window.admin = { + showMessage, + escapeHtml, + fetchEmbedSettings, + saveEmbedSetting, + copyIframeCode, + copyNewsletterIframeCode, + loadEmbedSettingsAndInit, + loadDashboardStats, + loadEmailTemplate, + saveEmailTemplate, + loadNewsletterStats, + previewNewsletter, + saveDraft, + sendNewsletter, + clearForm, + applyFilters, + clearFilters, + loadSubscribers, + displaySubscribers, + updatePagination, + changePage, + unsubscribe, + clearSubmissionFilters, + loadSubmissions, + displaySubmissions, + displaySubmissionPagination, + changeSubmissionPage, + deleteSubmission, +}; + +// Auto-initialize based on page content +document.addEventListener("DOMContentLoaded", function () { + // Embed page + if ( + document.getElementById("iframeCode") || + document.getElementById("iframeNewsletterCode") + ) { + loadEmbedSettingsAndInit(); + } + + // Dashboard + if (document.getElementById("contact-count")) { + loadDashboardStats(); + } + + // Email templates + if (document.getElementById("newsletterTemplate")) { + loadEmailTemplate(); + const form = document.getElementById("templateForm"); + if (form) { + form.addEventListener("submit", (e) => { + e.preventDefault(); + saveEmailTemplate(); + }); + } + } + + // Newsletter creation + if (document.getElementById("newsletterForm")) { + loadNewsletterStats(); + } + + // Newsletter subscribers + if (document.getElementById("subscribersTable")) { + loadSubscribers(); + } + + // Contact submissions + if (document.getElementById("submissionsTable")) { + loadSubmissions(); + + const filterForm = document.getElementById("filterForm"); + if (filterForm) { + filterForm.addEventListener("submit", (e) => { + e.preventDefault(); + submissionsCurrentPage = 1; + loadSubmissions(); + }); + } + + // Table sorting + document.querySelectorAll("th[data-sort]").forEach((header) => { + header.addEventListener("click", function () { + const sortBy = this.dataset.sort; + if (submissionsSortBy === sortBy) { + submissionsSortOrder = + submissionsSortOrder === "asc" ? "desc" : "asc"; + } else { + submissionsSortBy = sortBy; + submissionsSortOrder = "asc"; + } + submissionsCurrentPage = 1; + loadSubmissions(); + }); + }); + } + + // Settings + if (document.getElementById("settingsList")) { + loadSettingsForList(); + } +}); diff --git a/templates/_base.html b/templates/_base.html new file mode 100644 index 0000000..c9046cb --- /dev/null +++ b/templates/_base.html @@ -0,0 +1,15 @@ + + + + + {% block title %}Newsletter Subscribers{% endblock %} + + {% block extra_styles %}{% endblock %} + + + {% include "_nav.html" %} +

{% block heading %}Newsletter Subscribers{% endblock %}

+ {% block content %}{% endblock %} + {% block extra_scripts %}{% endblock %} + + diff --git a/templates/_nav.html b/templates/_nav.html new file mode 100644 index 0000000..5813a3a --- /dev/null +++ b/templates/_nav.html @@ -0,0 +1,53 @@ + diff --git a/templates/admin/admin_dashboard.html b/templates/admin/admin_dashboard.html new file mode 100644 index 0000000..1ae429e --- /dev/null +++ b/templates/admin/admin_dashboard.html @@ -0,0 +1,68 @@ +{% extends "_base.html" %} {% block title %}Admin Dashboard{% endblock %} {% +block heading %}Admin Dashboard{% endblock %} {% block extra_styles %} + {% endblock %} {% block +content %} +
+
+
+

--

+

Contact Submissions

+
+
+

--

+

Newsletter Subscribers

+
+
+

--

+

App Settings

+
+
+ +
+
+

Contact Form Submissions

+

View and manage contact form submissions from your website visitors.

+ Manage Submissions +
+ +
+

Newsletter Subscribers

+

+ Manage newsletter subscriptions and send newsletters to your subscribers. +

+ Manage Subscribers +
+ +
+

Application Settings

+

Configure application settings and environment variables.

+ Manage Settings +
+ +
+

Create Newsletter

+

Create and send newsletters to your subscribers.

+ Create Newsletter +
+ +
+

Embeddable Forms

+

+ Instructions for embedding contact and newsletter forms on other websites. +

+ View Embed Codes +
+
+ +
+ Logout +
+{% endblock %}{% block extra_scripts %} + + +{% endblock %} diff --git a/templates/admin/admin_email_templates.html b/templates/admin/admin_email_templates.html new file mode 100644 index 0000000..510c816 --- /dev/null +++ b/templates/admin/admin_email_templates.html @@ -0,0 +1,21 @@ +{% extends "_base.html" %} {% block title %}Email Templates{% endblock %} {% +block heading %}Email Templates{% endblock %} {% block extra_styles %} + {% endblock %} {% block +content %} +
+

Newsletter Confirmation Template

+

Edit the HTML template used for the newsletter confirmation email.

+
+
+
+ + +
+
+ +
+
+
+{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin/admin_embeds.html b/templates/admin/admin_embeds.html new file mode 100644 index 0000000..db29cdf --- /dev/null +++ b/templates/admin/admin_embeds.html @@ -0,0 +1,93 @@ +{% extends "_base.html" %} {% block title %}Embeddable Forms{% endblock %} {% +block heading %}Embeddable Forms{% endblock %} {% block extra_styles %} + {% endblock %} {% block +content %} +
+
+
+

Contact Form

+

+ Use the following HTML code to embed the contact form on other websites: +

+
+ + + + +
+ + +
+
+
+
+ +
+
+
+

Newsletter Subscription Form

+

+ Use the following HTML code to embed the newsletter subscription form on + other websites: +

+
+ + + + +
+ + +
+
+
+
+
+

+ Replace http://your-server-domain with your actual server + domain and port (e.g., https://yourdomain.com). +

+
+ {% endblock %} {% block extra_scripts %} + + {% endblock %} + diff --git a/templates/admin/admin_newsletter.html b/templates/admin/admin_newsletter.html new file mode 100644 index 0000000..9fa29ac --- /dev/null +++ b/templates/admin/admin_newsletter.html @@ -0,0 +1,42 @@ +{% extends "_base.html" %} {% block title %}Newsletter Subscribers{% endblock %} +{% block heading %}Newsletter Subscribers{% endblock %} {% block extra_styles %} + {% endblock %} {% block +content %} + +
+ +
+ + + + + +
+ +
Loading subscribers...
+ + + + + + + + + + + + + +{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin/admin_newsletter_create.html b/templates/admin/admin_newsletter_create.html new file mode 100644 index 0000000..790b5af --- /dev/null +++ b/templates/admin/admin_newsletter_create.html @@ -0,0 +1,98 @@ +{% extends "_base.html" %} {% block title %}Create Newsletter{% endblock %} {% +block heading %}Create Newsletter{% endblock %} {% block extra_styles %} + {% endblock %} {% block +content %} +
+ + + +
+
+

Newsletter Details

+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+

Actions

+ + + + +
+
+ + +{% endblock %}{% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin/admin_settings.html b/templates/admin/admin_settings.html new file mode 100644 index 0000000..bf20f9f --- /dev/null +++ b/templates/admin/admin_settings.html @@ -0,0 +1,59 @@ +{% extends "_base.html" %} {% block title %}Settings{% endblock %} {% block +heading %}Application Settings{% endblock %} {% block extra_styles %} + +{% endblock %} {% block content %} +
+ {% for category, category_settings in settings.items() %} +
+

{{ category }}

+
+ {% for key, value in category_settings.items() %} +
{{ key }}: {{ value }}
+ {% endfor %} +
+
+ {% endfor %} +
+ +
+

Dynamic Settings Management

+ +
+ +
+

Loading settings...

+
+ +

Add New Setting

+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin/admin_submissions.html b/templates/admin/admin_submissions.html new file mode 100644 index 0000000..515d9ee --- /dev/null +++ b/templates/admin/admin_submissions.html @@ -0,0 +1,80 @@ +{% extends "_base.html" %} {% block title %}Contact Submissions{% endblock %} {% +block heading %}Contact Form Submissions{% endblock %} {% block extra_styles %} + + +{% endblock %} {% block content %} +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + +
IDNameEmailCompanyMessageDateActions
Loading submissions...
+ + + +{% endblock %} {% block extra_scripts %} + +{% endblock %} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html deleted file mode 100644 index 670d3d7..0000000 --- a/templates/admin_dashboard.html +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - Admin Dashboard - - - - -

Admin Dashboard

- -
-
-

--

-

Contact Submissions

-
-
-

--

-

Newsletter Subscribers

-
-
-

--

-

App Settings

-
-
- -
-
-

Contact Form Submissions

-

- View and manage contact form submissions from your website visitors. -

- Manage Submissions -
- -
-

Newsletter Subscribers

-

- Manage newsletter subscriptions and send newsletters to your - subscribers. -

- Manage Subscribers -
- -
-

Application Settings

-

Configure application settings and environment variables.

- Manage Settings -
- -
-

Create Newsletter

-

Create and send newsletters to your subscribers.

- Create Newsletter -
- -
-

Embeddable Forms

-

- Instructions for embedding contact and newsletter forms on other - websites. -

- View Embed Codes -
-
- -
- Logout -
- - - - diff --git a/templates/admin_embeds.html b/templates/admin_embeds.html deleted file mode 100644 index ccd94ac..0000000 --- a/templates/admin_embeds.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - Embeddable Forms - - - - - - -

Embeddable Forms

- -
-

Contact Form

-

- Use the following HTML code to embed the contact form on other websites: -

- - -
- -
-

Newsletter Subscription Form

-

- Use the following HTML code to embed the newsletter subscription form on - other websites: -

- - -
- -
-

- Replace http://your-server-domain with your actual server - domain and port (e.g., https://yourdomain.com). -

-
- - - - diff --git a/templates/admin_newsletter.html b/templates/admin_newsletter.html deleted file mode 100644 index 9d7cb50..0000000 --- a/templates/admin_newsletter.html +++ /dev/null @@ -1,362 +0,0 @@ - - - - - - Newsletter Subscribers - - - - - - -

Newsletter Subscribers

- -
- -
- - - - - -
- -
Loading subscribers...
- - - - - - - - - - - - - - - - diff --git a/templates/admin_newsletter_create.html b/templates/admin_newsletter_create.html deleted file mode 100644 index 9824bd6..0000000 --- a/templates/admin_newsletter_create.html +++ /dev/null @@ -1,458 +0,0 @@ - - - - - - Create Newsletter - - - - - - -

Create Newsletter

- -
- - - -
-
-

Newsletter Details

-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
- - -
-
-
- -
-

Actions

- - - - -
-
- - - - - - diff --git a/templates/admin_settings.html b/templates/admin_settings.html deleted file mode 100644 index cdd24e5..0000000 --- a/templates/admin_settings.html +++ /dev/null @@ -1,337 +0,0 @@ - - - - - - Admin Settings - - - - - - -

Application Settings

- - {% for category, category_settings in settings.items() %} -
-

{{ category }}

- {% for key, value in category_settings.items() %} -
{{ key }}: {{ value }}
- {% endfor %} -
- {% endfor %} - -
-

Dynamic Settings Management

- -
- -
-

Loading settings...

-
- -

Add New Setting

-
-
-
- - -
-
- - -
-
- -
-
-
-
- -
-

Embeddable Forms

-

- Manage embeddable forms on the Embeds page. -

-
- -
-

Email Templates

-
-

Loading email templates...

-
-
- - - - diff --git a/templates/admin_submissions.html b/templates/admin_submissions.html deleted file mode 100644 index 948fd0b..0000000 --- a/templates/admin_submissions.html +++ /dev/null @@ -1,303 +0,0 @@ - - - - - - Contact Submissions - - - - - - -

Contact Form Submissions

- -
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
- - -
-
- - - - - - - - - - - - - - - - - - - - -
IDNameEmailCompanyMessageDateActions
Loading submissions...
- - - - - - diff --git a/templates/unsubscribe_confirmation.html b/templates/unsubscribe_confirmation.html index ad978d8..2c5c71e 100644 --- a/templates/unsubscribe_confirmation.html +++ b/templates/unsubscribe_confirmation.html @@ -32,7 +32,7 @@ If you unsubscribed by mistake or have any questions, please feel free to contact us.

- Return to Homepage + Return to Newsletter diff --git a/tests/test_email_templates_api.py b/tests/test_email_templates_api.py new file mode 100644 index 0000000..b2292b4 --- /dev/null +++ b/tests/test_email_templates_api.py @@ -0,0 +1,43 @@ +import importlib +import pytest + +server_app_module = importlib.import_module("server.app") +app = server_app_module.app + + +@pytest.fixture +def client(): + with app.test_client() as client: + yield client + + +def test_get_settings_returns_dict(client): + # Login as admin first + client.post('/auth/login', data={'username': 'admin', 'password': 'admin'}) + resp = client.get('/admin/api/settings') + assert resp.status_code == 200 + body = resp.get_json() + assert body['status'] == 'ok' + assert isinstance(body.get('settings'), dict) + + +def test_update_and_get_newsletter_template(client): + key = 'newsletter_confirmation_template' + sample = '

Thanks for subscribing, {{email}}

' + + # Update via PUT + # Login as admin first + client.post('/auth/login', data={'username': 'admin', 'password': 'admin'}) + + resp = client.put(f'/admin/api/settings/{key}', json={'value': sample}) + assert resp.status_code == 200 + body = resp.get_json() + assert body['status'] == 'ok' + + # Retrieve via GET and ensure the value is present + resp = client.get('/admin/api/settings') + assert resp.status_code == 200 + body = resp.get_json() + assert body['status'] == 'ok' + settings = body.get('settings') or {} + assert settings.get(key) == sample