feat(newsletter): Add subscription confirmation email functionality
All checks were successful
CI / test (3.11) (push) Successful in 9m26s
CI / build-image (push) Successful in 49s

- Implemented `send_subscription_confirmation` function to send a confirmation email upon subscription.
- Added a default HTML template for the confirmation email in settings.
- Updated the newsletter management page to handle subscription and unsubscription actions.
- Created new templates for embedding contact and newsletter forms.
- Added styles for the new templates and unified CSS styles across the application.
- Updated tests to reflect changes in the newsletter management API endpoints.
This commit is contained in:
2025-10-30 12:38:26 +01:00
parent f7695be8ef
commit 56840ac313
24 changed files with 897 additions and 449 deletions

View File

@@ -61,11 +61,12 @@ Access the admin interface at `http://127.0.0.1:5002/auth/login` using the confi
- `GET /api/contact`: retrieves contact form submissions (admin only, requires authentication). Supports pagination (`page`, `per_page`), filtering (`email`, `date_from`, `date_to`), and sorting (`sort_by`, `sort_order`). - `GET /api/contact`: retrieves contact form submissions (admin only, requires authentication). Supports pagination (`page`, `per_page`), filtering (`email`, `date_from`, `date_to`), and sorting (`sort_by`, `sort_order`).
- `GET /api/contact/<id>`: retrieves a specific contact submission by ID (admin only). - `GET /api/contact/<id>`: retrieves a specific contact submission by ID (admin only).
- `DELETE /api/contact/<id>`: deletes a contact submission by ID (admin only). - `DELETE /api/contact/<id>`: deletes a contact submission by ID (admin only).
- `POST /api/newsletter`: subscribes an address and optional metadata to the newsletter list. - `POST /api/newsletter`: subscribes an address and optional metadata to the newsletter list. Sends a confirmation email if SMTP is configured.
- `DELETE /api/newsletter`: unsubscribes an email address from the newsletter list. - `DELETE /api/newsletter`: unsubscribes an email address from the newsletter list.
- `PUT /api/newsletter`: updates a subscriber's email address (requires `old_email` and `new_email`). - `PUT /api/newsletter`: updates a subscriber's email address (requires `old_email` and `new_email`).
- `GET /api/newsletter/manage`: displays HTML form for newsletter subscription management. - `GET /api/newsletter/manage`: displays HTML form for newsletter subscription management.
- `POST /api/newsletter/manage`: processes subscription management actions (subscribe, unsubscribe, update). - `POST /api/newsletter/manage`: processes subscription management actions (subscribe, unsubscribe, update).
- `GET /newsletter/unsubscribe/confirmation`: displays unsubscription confirmation page.
- `GET /health`: lightweight database connectivity check used for container health monitoring. - `GET /health`: lightweight database connectivity check used for container health monitoring.
- `GET /metrics`: Prometheus-compatible metrics endpoint (requires `ENABLE_REQUEST_LOGS` for detailed tracing). - `GET /metrics`: Prometheus-compatible metrics endpoint (requires `ENABLE_REQUEST_LOGS` for detailed tracing).
- `GET /admin/api/settings`: retrieves all application settings (admin only). - `GET /admin/api/settings`: retrieves all application settings (admin only).
@@ -77,6 +78,41 @@ Access the admin interface at `http://127.0.0.1:5002/auth/login` using the confi
- `POST /admin/api/newsletters/<id>/send`: sends a newsletter to all subscribers (admin only). - `POST /admin/api/newsletters/<id>/send`: sends a newsletter to all subscribers (admin only).
- `GET /admin/api/contact`: retrieves contact form submissions with pagination, filtering, and sorting (admin only). - `GET /admin/api/contact`: retrieves contact form submissions with pagination, filtering, and sorting (admin only).
- `DELETE /admin/api/contact/<id>`: deletes a contact submission by ID (admin only). - `DELETE /admin/api/contact/<id>`: deletes a contact submission by ID (admin only).
- `GET /embed/contact`: serves an HTML page with a contact form that can be embedded in an iframe on external sites.
- `GET /embed/newsletter`: serves an HTML page with a newsletter subscription form that can be embedded in an iframe on external sites.
## Embeddable Forms
The application provides embeddable contact and newsletter subscription forms that can be integrated into other websites via iframe.
- `GET /embed/contact`: serves an HTML page with a contact form that can be embedded in an iframe on external sites.
- `GET /embed/newsletter`: serves an HTML page with a newsletter subscription form that can be embedded in an iframe on external sites.
To embed the contact form on another website, use the following HTML code:
```html
<iframe
src="https://your-server-domain/embed/contact"
width="600"
height="400"
frameborder="0"
allowfullscreen
></iframe>
```
To embed the newsletter subscription form:
```html
<iframe
src="https://your-server-domain/embed/newsletter"
width="600"
height="300"
frameborder="0"
allowfullscreen
></iframe>
```
Replace `https://your-server-domain` with your actual server URL. The iframe codes are available in the admin settings page for easy copying.
## Running With Docker ## Running With Docker
@@ -203,3 +239,13 @@ SMTP integration tests are skipped unless `RUN_SMTP_INTEGRATION_TEST=1` and vali
- When the default branch (`main`) runs and registry secrets (`REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`) are configured in Gitea, the workflow logs in and pushes both `latest` and commit-specific image tags. - When the default branch (`main`) runs and registry secrets (`REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`) are configured in Gitea, the workflow logs in and pushes both `latest` and commit-specific image tags.
- Import or mirror the required reusable actions (`actions/checkout`, `actions/setup-python`, and the Docker actions) into your Gitea instance so that the workflow can resolve them. - Import or mirror the required reusable actions (`actions/checkout`, `actions/setup-python`, and the Docker actions) into your Gitea instance so that the workflow can resolve them.
- For production use, deploy the container behind a load balancer or reverse proxy and supply the appropriate environment variables. - For production use, deploy the container behind a load balancer or reverse proxy and supply the appropriate environment variables.
## Email Templates
The application supports customizable email templates for newsletter confirmations. The confirmation email template can be edited through the admin settings page under the dynamic settings management. The template uses HTML format and should include an unsubscribe link for compliance with email regulations.
Default confirmation email includes:
- Welcome message
- Unsubscribe instructions with link to `/newsletter/manage`
- Customizable HTML content

1
iframe_contact.html Normal file
View File

@@ -0,0 +1 @@
<iframe src="http://your-server-domain/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>

1
iframe_newsletter.html Normal file
View File

@@ -0,0 +1 @@
<iframe src="http://your-server-domain/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from flask import Flask from flask import Flask
from . import admin, auth, contact, monitoring, newsletter from . import admin, auth, contact, embed, monitoring, newsletter
def register_blueprints(app: Flask) -> None: def register_blueprints(app: Flask) -> None:
@@ -13,3 +13,4 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(monitoring.bp) app.register_blueprint(monitoring.bp)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(admin.bp) app.register_blueprint(admin.bp)
app.register_blueprint(embed.bp)

View File

@@ -82,6 +82,13 @@ def submissions():
return render_template("admin_submissions.html") return render_template("admin_submissions.html")
@bp.route("/embeds")
@auth.login_required
def embeds():
"""Display embeddable forms management page."""
return render_template("admin_embeds.html")
@bp.route("/api/settings", methods=["GET"]) @bp.route("/api/settings", methods=["GET"])
@auth.login_required @auth.login_required
def get_settings_api(): def get_settings_api():

26
server/routes/embed.py Normal file
View File

@@ -0,0 +1,26 @@
"""Embeddable forms routes."""
from __future__ import annotations
from flask import Blueprint, render_template, send_from_directory
import os
bp = Blueprint("embed", __name__)
@bp.route("/embed/contact", methods=["GET"])
def contact_form():
"""Serve the embeddable contact form."""
return render_template("embed_contact.html")
@bp.route("/embed/newsletter", methods=["GET"])
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')

View File

@@ -7,10 +7,10 @@ from flask import Blueprint, jsonify, request, render_template
from ..services import newsletter from ..services import newsletter
bp = Blueprint("newsletter", __name__, url_prefix="/api") bp = Blueprint("newsletter", __name__)
@bp.route("/newsletter", methods=["POST"]) @bp.route("/api/newsletter", methods=["POST"])
def subscribe(): def subscribe():
payload = request.form or request.get_json(silent=True) or {} payload = request.form or request.get_json(silent=True) or {}
email = (payload.get("email") or "").strip() email = (payload.get("email") or "").strip()
@@ -29,10 +29,16 @@ def subscribe():
return jsonify({"status": "error", "message": "Email is already subscribed."}), 409 return jsonify({"status": "error", "message": "Email is already subscribed."}), 409
logging.info("New newsletter subscription: %s", email) logging.info("New newsletter subscription: %s", email)
# Send confirmation email
email_sent = newsletter.send_subscription_confirmation(email)
if not email_sent:
logging.warning("Confirmation email not sent for %s", email)
return jsonify({"status": "ok", "message": "Subscribed successfully."}), 201 return jsonify({"status": "ok", "message": "Subscribed successfully."}), 201
@bp.route("/newsletter", methods=["DELETE"]) @bp.route("/api/newsletter", methods=["DELETE"])
def unsubscribe(): def unsubscribe():
payload = request.form or request.get_json(silent=True) or {} payload = request.form or request.get_json(silent=True) or {}
email = (payload.get("email") or "").strip() email = (payload.get("email") or "").strip()
@@ -55,7 +61,7 @@ def unsubscribe():
return jsonify({"status": "ok", "message": "Unsubscribed successfully."}), 200 return jsonify({"status": "ok", "message": "Unsubscribed successfully."}), 200
@bp.route("/newsletter", methods=["PUT"]) @bp.route("/api/newsletter", methods=["PUT"])
def update_subscription(): def update_subscription():
payload = request.form or request.get_json(silent=True) or {} payload = request.form or request.get_json(silent=True) or {}
old_email = (payload.get("old_email") or "").strip() old_email = (payload.get("old_email") or "").strip()
@@ -104,8 +110,7 @@ def manage_subscription():
elif action == "unsubscribe": elif action == "unsubscribe":
deleted = newsletter.unsubscribe(email) deleted = newsletter.unsubscribe(email)
if deleted: if deleted:
message = "Successfully unsubscribed from newsletter." return render_template("unsubscribe_confirmation.html")
message_type = "success"
else: else:
message = "This email is not currently subscribed." message = "This email is not currently subscribed."
message_type = "info" message_type = "info"
@@ -131,3 +136,9 @@ def manage_subscription():
message_type = "error" message_type = "error"
return render_template("newsletter_manage.html", message=message, message_type=message_type) return render_template("newsletter_manage.html", message=message, message_type=message_type)
@bp.route("/newsletter/unsubscribe/confirmation", methods=["GET"])
def unsubscribe_confirmation():
"""Display newsletter unsubscription confirmation page."""
return render_template("unsubscribe_confirmation.html")

View File

@@ -28,6 +28,61 @@ def update_email(old_email: str, new_email: str) -> bool:
return update_subscriber(old_email, new_email) return update_subscriber(old_email, new_email)
def send_subscription_confirmation(email: str) -> bool:
"""Send a confirmation email to the subscriber."""
import logging
from .. import settings
if not settings.SMTP_SETTINGS["host"]:
logging.info("SMTP not configured; skipping confirmation email")
return False
# Get template from settings or default
template = getattr(settings, 'NEWSLETTER_CONFIRMATION_TEMPLATE', """
<html>
<body>
<h2>Welcome to our Newsletter!</h2>
<p>Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.</p>
<p>If you wish to unsubscribe at any time, you can do so by visiting our <a href="https://your-domain.com/newsletter/manage">subscription management page</a>.</p>
<p>Best regards,<br>The Team</p>
</body>
</html>
""").strip()
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
sender = settings.SMTP_SETTINGS["sender"] or "noreply@example.com"
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = "Newsletter Subscription Confirmation"
msg['From'] = sender
msg['To'] = email
# Add HTML content
html_part = MIMEText(template, 'html')
msg.attach(html_part)
# Send email
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"], timeout=15) as server:
if settings.SMTP_SETTINGS["use_tls"]:
server.starttls()
if settings.SMTP_SETTINGS["username"]:
server.login(
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"] or "")
server.send_message(msg)
logging.info("Confirmation email sent to %s", email)
return True
except Exception as exc:
logging.exception("Failed to send confirmation email to %s: %s", email, exc)
return False
def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str], sender_name: str | None = None) -> int: def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str], sender_name: str | None = None) -> int:
"""Send newsletter to list of email addresses. Returns count of successful sends.""" """Send newsletter to list of email addresses. Returns count of successful sends."""
import logging import logging

View File

@@ -63,3 +63,14 @@ if not SMTP_SETTINGS["sender"] and SMTP_SETTINGS["username"]:
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
NEWSLETTER_CONFIRMATION_TEMPLATE = os.getenv("NEWSLETTER_CONFIRMATION_TEMPLATE", """
<html>
<body>
<h2>Welcome to our Newsletter!</h2>
<p>Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.</p>
<p>If you wish to unsubscribe at any time, you can do so by visiting our <a href="https://your-domain.com/newsletter/manage">subscription management page</a>.</p>
<p>Best regards,<br>The Team</p>
</body>
</html>
""").strip()

335
static/css/styles.css Normal file
View File

@@ -0,0 +1,335 @@
/* Unified CSS Styles */
/* Base styles */
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f9f9f9;
color: #333;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
h2 {
color: #555;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
margin-bottom: 20px;
}
/* Form styles */
.form-container,
.contact-form,
.confirmation-container {
max-width: 600px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.form-group,
.setting {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
input,
textarea,
select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
textarea {
height: 100px;
resize: vertical;
}
button,
.btn {
background-color: #007bff;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
text-decoration: none;
display: inline-block;
}
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;
}
/* Message styles */
.message {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
/* Admin dashboard */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.dashboard-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background-color: #f9f9f9;
transition: box-shadow 0.3s ease;
}
.dashboard-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.dashboard-card h2 {
color: #555;
margin-top: 0;
margin-bottom: 15px;
}
.dashboard-card p {
color: #666;
margin-bottom: 15px;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 30px;
flex-wrap: wrap;
}
.stat-card {
background-color: #e9ecef;
padding: 15px;
border-radius: 8px;
text-align: center;
min-width: 150px;
margin: 5px;
}
.stat-card h3 {
margin: 0;
color: #495057;
font-size: 2em;
}
.stat-card p {
margin: 5px 0 0 0;
color: #6c757d;
font-size: 0.9em;
}
.logout {
text-align: center;
margin-top: 40px;
}
.logout a {
color: #dc3545;
text-decoration: none;
}
.logout a:hover {
text-decoration: underline;
}
/* Admin settings */
.setting-group {
margin-bottom: 20px;
}
.setting strong {
display: inline-block;
width: 200px;
}
.settings-management {
margin-top: 40px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.settings-list {
margin-bottom: 20px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info {
flex-grow: 1;
}
.setting-actions {
display: flex;
gap: 10px;
}
.form-row {
display: flex;
gap: 10px;
align-items: end;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.edit-form {
display: none;
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border-radius: 3px;
}
/* Newsletter manage */
.form-section {
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
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%;
border-collapse: collapse;
margin-bottom: 20px;
}
th,
td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
}
tr:hover {
background-color: #f5f5f5;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
.pagination a,
.pagination span {
padding: 8px 12px;
text-decoration: none;
border: 1px solid #ddd;
border-radius: 4px;
color: #007bff;
}
.pagination a:hover {
background-color: #007bff;
color: white;
}
.pagination .current {
background-color: #007bff;
color: white;
}
/* Utility classes */
.text-center {
text-align: center;
}
.mt-20 {
margin-top: 20px;
}
.mb-20 {
margin-bottom: 20px;
}

View File

@@ -4,91 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Dashboard</title> <title>Admin Dashboard</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
} }
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.dashboard-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
background-color: #f9f9f9;
transition: box-shadow 0.3s ease;
}
.dashboard-card:hover {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.dashboard-card h2 {
color: #555;
margin-top: 0;
margin-bottom: 15px;
}
.dashboard-card p {
color: #666;
margin-bottom: 15px;
}
.dashboard-card a {
display: inline-block;
background-color: #007bff;
color: white;
padding: 8px 16px;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.dashboard-card a:hover {
background-color: #0056b3;
}
.logout {
text-align: center;
margin-top: 40px;
}
.logout a {
color: #dc3545;
text-decoration: none;
}
.logout a:hover {
text-decoration: underline;
}
.stats {
display: flex;
justify-content: space-around;
margin-bottom: 30px;
flex-wrap: wrap;
}
.stat-card {
background-color: #e9ecef;
padding: 15px;
border-radius: 8px;
text-align: center;
min-width: 150px;
margin: 5px;
}
.stat-card h3 {
margin: 0;
color: #495057;
font-size: 2em;
}
.stat-card p {
margin: 5px 0 0 0;
color: #6c757d;
font-size: 0.9em;
}
</style> </style>
</head> </head>
<body> <body>
@@ -138,6 +60,15 @@
<p>Create and send newsletters to your subscribers.</p> <p>Create and send newsletters to your subscribers.</p>
<a href="/admin/newsletter/create">Create Newsletter</a> <a href="/admin/newsletter/create">Create Newsletter</a>
</div> </div>
<div class="dashboard-card">
<h2>Embeddable Forms</h2>
<p>
Instructions for embedding contact and newsletter forms on other
websites.
</p>
<a href="/admin/embeds">View Embed Codes</a>
</div>
</div> </div>
<div class="logout"> <div class="logout">

112
templates/admin_embeds.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embeddable Forms</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
.iframe-code {
width: 100%;
height: 100px;
font-family: monospace;
padding: 10px;
border: 1px solid #ccc;
border-radius: 3px;
}
</style>
</head>
<body>
<div class="logout">
<a
href="/admin/"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Dashboard</a
>
<a
href="/admin/submissions"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>View Submissions</a
>
<a
href="/admin/settings"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Settings</a
>
<a
href="{{ url_for('auth.logout') }}"
style="color: #007bff; text-decoration: none"
>Logout</a
>
</div>
<h1>Embeddable Forms</h1>
<div class="settings-management">
<h2>Contact Form</h2>
<p>
Use the following HTML code to embed the contact form on other websites:
</p>
<textarea id="iframeCode" class="iframe-code" readonly>
<iframe src="http://your-server-domain/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
</textarea>
<button class="btn btn-secondary" onclick="copyIframeCode()">
Copy Contact Iframe
</button>
</div>
<div class="settings-management">
<h2>Newsletter Subscription Form</h2>
<p>
Use the following HTML code to embed the newsletter subscription form on
other websites:
</p>
<textarea id="iframeNewsletterCode" class="iframe-code" readonly>
<iframe src="http://your-server-domain/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
</textarea>
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
Copy Newsletter Iframe
</button>
</div>
<div class="settings-management">
<p>
Replace <code>http://your-server-domain</code> with your actual server
domain and port (e.g., https://yourdomain.com).
</p>
</div>
<script>
function copyIframeCode() {
const textarea = document.getElementById("iframeCode");
textarea.select();
document.execCommand("copy");
showMessage("Contact iframe code copied to clipboard!", "success");
}
function copyNewsletterIframeCode() {
const textarea = document.getElementById("iframeNewsletterCode");
textarea.select();
document.execCommand("copy");
showMessage("Newsletter iframe code copied to clipboard!", "success");
}
function showMessage(text, type) {
// Create message div if it doesn't exist
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);
}
</script>
</body>
</html>

View File

@@ -4,10 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Newsletter Subscribers</title> <title>Newsletter Subscribers</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;

View File

@@ -4,10 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create Newsletter</title> <title>Create Newsletter</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;

View File

@@ -4,134 +4,20 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Settings</title> <title>Admin Settings</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
h1 {
color: #333;
}
h2 {
color: #555;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
}
.setting-group {
margin-bottom: 20px;
}
.setting {
margin: 5px 0;
}
.setting strong { .setting strong {
display: inline-block; display: inline-block;
width: 200px; width: 200px;
} }
.logout { .iframe-code {
margin-top: 20px;
}
.logout a {
color: #007bff;
text-decoration: none;
}
.logout a:hover {
text-decoration: underline;
}
.settings-management {
margin-top: 40px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
}
.settings-list {
margin-bottom: 20px;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-info {
flex-grow: 1;
}
.setting-actions {
display: flex;
gap: 10px;
}
.btn {
padding: 5px 10px;
border: none;
border-radius: 3px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn:hover {
opacity: 0.8;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input,
.form-group select {
width: 100%; width: 100%;
padding: 8px; height: 100px;
font-family: monospace;
padding: 10px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 3px; border-radius: 3px;
} }
.form-row {
display: flex;
gap: 10px;
align-items: end;
}
.form-row .form-group {
flex: 1;
margin-bottom: 0;
}
.message {
padding: 10px;
margin: 10px 0;
border-radius: 3px;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.edit-form {
display: none;
margin-top: 10px;
padding: 10px;
background: #f8f9fa;
border-radius: 3px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -146,6 +32,11 @@
style="color: #007bff; text-decoration: none; margin-right: 20px" style="color: #007bff; text-decoration: none; margin-right: 20px"
>View Submissions</a >View Submissions</a
> >
<a
href="/admin/embeds"
style="color: #007bff; text-decoration: none; margin-right: 20px"
>Embeds</a
>
<a <a
href="{{ url_for('auth.logout') }}" href="{{ url_for('auth.logout') }}"
style="color: #007bff; text-decoration: none" style="color: #007bff; text-decoration: none"
@@ -203,6 +94,20 @@
</form> </form>
</div> </div>
<div class="settings-management">
<h2>Embeddable Forms</h2>
<p>
Manage embeddable forms on the <a href="/admin/embeds">Embeds page</a>.
</p>
</div>
<div class="settings-management">
<h2>Email Templates</h2>
<div id="emailTemplates">
<p>Loading email templates...</p>
</div>
</div>
<script> <script>
let appSettings = {}; let appSettings = {};
@@ -257,11 +162,21 @@
} }
const settingsHtml = Object.entries(appSettings) const settingsHtml = Object.entries(appSettings)
.map( .map(([key, value]) => {
([key, value]) => ` const isTemplate = key === "newsletter_confirmation_template";
const inputType = isTemplate ? "textarea" : "input";
const inputAttrs = isTemplate
? 'rows="10" cols="50"'
: 'type="text"';
return `
<div class="setting-item"> <div class="setting-item">
<div class="setting-info"> <div class="setting-info">
<strong>${escapeHtml(key)}:</strong> ${escapeHtml(value)} <strong>${escapeHtml(key)}:</strong> ${
isTemplate
? "<em>HTML template</em>"
: escapeHtml(value.substring(0, 50)) +
(value.length > 50 ? "..." : "")
}
</div> </div>
<div class="setting-actions"> <div class="setting-actions">
<button class="btn btn-secondary" onclick="editSetting('${escapeHtml( <button class="btn btn-secondary" onclick="editSetting('${escapeHtml(
@@ -280,9 +195,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>New Value:</label> <label>New Value:</label>
<input type="text" id="edit-value-${escapeHtml( <${inputType} id="edit-value-${escapeHtml(
key key
)}" value="${escapeHtml(value)}" required> )}" ${inputAttrs} required>${escapeHtml(value)}</${inputType}>
</div> </div>
<div class="form-group"> <div class="form-group">
<button class="btn btn-primary" onclick="updateSetting('${escapeHtml( <button class="btn btn-primary" onclick="updateSetting('${escapeHtml(
@@ -294,8 +209,8 @@
</div> </div>
</div> </div>
</div> </div>
` `;
) })
.join(""); .join("");
container.innerHTML = settingsHtml; container.innerHTML = settingsHtml;

View File

@@ -4,29 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact Submissions</title> <title>Contact Submissions</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
} }
h1 {
color: #333;
margin-bottom: 20px;
}
.nav {
margin-bottom: 20px;
}
.nav a {
color: #007bff;
text-decoration: none;
margin-right: 20px;
}
.nav a:hover {
text-decoration: underline;
}
.filters { .filters {
background: #f8f9fa; background: #f8f9fa;
padding: 15px; padding: 15px;
@@ -39,124 +23,6 @@
flex-wrap: wrap; flex-wrap: wrap;
align-items: end; align-items: end;
} }
.filters label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.filters input,
.filters select {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
.filters button {
padding: 8px 15px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.filters button:hover {
background: #0056b3;
}
.filters .clear-btn {
background: #6c757d;
}
.filters .clear-btn:hover {
background: #545b62;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th,
td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f8f9fa;
font-weight: bold;
cursor: pointer;
}
th:hover {
background-color: #e9ecef;
}
th.sort-asc::after {
content: " ↑";
}
th.sort-desc::after {
content: " ↓";
}
tr:hover {
background-color: #f5f5f5;
}
.message {
padding: 8px;
margin: 10px 0;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.pagination {
margin-top: 20px;
text-align: center;
}
.pagination button {
padding: 8px 12px;
margin: 0 2px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
}
.pagination button:hover {
background: #f8f9fa;
}
.pagination button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
}
.delete-btn:hover {
background: #c82333;
}
.loading {
text-align: center;
padding: 20px;
}
.no-data {
text-align: center;
padding: 40px;
color: #666;
}
.submission-details {
max-width: 300px;
word-wrap: break-word;
}
</style> </style>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Contact Form</title>
<link rel="stylesheet" href="/static/css/styles.css" />
</head>
<body>
<div class="contact-form">
<h2>Contact Us</h2>
<form id="contactForm" method="post" action="/api/contact">
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required />
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required />
</div>
<div class="form-group">
<label for="message">Message:</label>
<textarea id="message" name="message" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
<div id="responseMessage"></div>
</div>
<script>
document
.getElementById("contactForm")
.addEventListener("submit", function (e) {
e.preventDefault();
const formData = new FormData(this);
fetch("/api/contact", {
method: "POST",
body: formData,
})
.then((response) => response.json())
.then((data) => {
const messageDiv = document.getElementById("responseMessage");
if (data.status === "ok") {
messageDiv.innerHTML =
'<div class="message success">Thank you for your message! We will get back to you soon.</div>';
this.reset();
} else {
messageDiv.innerHTML =
'<div class="message error">There was an error sending your message. Please try again.</div>';
}
})
.catch((error) => {
console.error("Error:", error);
document.getElementById("responseMessage").innerHTML =
'<div class="message error">There was an error sending your message. Please try again.</div>';
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Newsletter Subscription</title>
<link rel="stylesheet" href="/static/css/styles.css" />
</head>
<body>
<div class="form-container">
<h2>Subscribe to Our Newsletter</h2>
<form id="newsletterForm" method="post" action="/api/newsletter">
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required />
</div>
<button type="submit">Subscribe</button>
</form>
<div id="responseMessage"></div>
</div>
<script>
document
.getElementById("newsletterForm")
.addEventListener("submit", function (e) {
e.preventDefault();
const formData = new FormData(this);
fetch("/api/newsletter", {
method: "POST",
body: formData,
})
.then((response) => response.json())
.then((data) => {
const messageDiv = document.getElementById("responseMessage");
if (data.status === "ok") {
messageDiv.innerHTML =
'<div class="message success">Thank you for subscribing! Please check your email for confirmation.</div>';
this.reset();
} else {
messageDiv.innerHTML =
'<div class="message error">' +
(data.message ||
"There was an error subscribing. Please try again.") +
"</div>";
}
})
.catch((error) => {
console.error("Error:", error);
document.getElementById("responseMessage").innerHTML =
'<div class="message error">There was an error subscribing. Please try again.</div>';
});
});
</script>
</body>
</html>

View File

@@ -4,27 +4,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin Login</title> <title>Admin Login</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { .login-form {
font-family: Arial, sans-serif;
margin: 40px;
}
form {
max-width: 300px; max-width: 300px;
margin: auto; margin: 40px auto;
} }
input { .login-form input {
display: block;
margin: 10px 0; margin: 10px 0;
padding: 8px;
width: 100%;
}
button {
padding: 10px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
} }
.flash { .flash {
color: red; color: red;
@@ -34,13 +21,22 @@
<body> <body>
<h1>Admin Login</h1> <h1>Admin Login</h1>
{% with messages = get_flashed_messages() %} {% if messages %} {% with messages = get_flashed_messages() %} {% if messages %}
<div class="flash"> <div class="message error">
{% for message in messages %} {{ message }} {% endfor %} {% for message in messages %} {{ message }} {% endfor %}
</div> </div>
{% endif %} {% endwith %} {% endif %} {% endwith %}
<form method="post"> <form method="post" class="login-form">
<div class="form-group">
<input type="text" name="username" placeholder="Username" required /> <input type="text" name="username" placeholder="Username" required />
<input type="password" name="password" placeholder="Password" required /> </div>
<div class="form-group">
<input
type="password"
name="password"
placeholder="Password"
required
/>
</div>
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
</body> </body>

View File

@@ -4,69 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Newsletter Management</title> <title>Newsletter Management</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style> <style>
body { body {
font-family: Arial, sans-serif;
margin: 20px;
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 20px auto;
padding: 20px; padding: 20px;
} }
h1 {
color: #333;
text-align: center;
}
.message {
padding: 10px;
margin: 10px 0;
border-radius: 4px;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.form-section {
margin: 20px 0;
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
}
.form-section h2 {
margin-top: 0;
color: #555;
}
form { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
} }
input[type="email"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.unsubscribe-btn { .unsubscribe-btn {
background-color: #dc3545; background-color: #dc3545;
} }
@@ -93,12 +42,14 @@
<h2>Subscribe to Newsletter</h2> <h2>Subscribe to Newsletter</h2>
<form method="post"> <form method="post">
<input type="hidden" name="action" value="subscribe" /> <input type="hidden" name="action" value="subscribe" />
<div class="form-group">
<input <input
type="email" type="email"
name="email" name="email"
placeholder="Enter your email address" placeholder="Enter your email address"
required required
/> />
</div>
<button type="submit">Subscribe</button> <button type="submit">Subscribe</button>
</form> </form>
</div> </div>
@@ -107,12 +58,14 @@
<h2>Unsubscribe from Newsletter</h2> <h2>Unsubscribe from Newsletter</h2>
<form method="post"> <form method="post">
<input type="hidden" name="action" value="unsubscribe" /> <input type="hidden" name="action" value="unsubscribe" />
<div class="form-group">
<input <input
type="email" type="email"
name="email" name="email"
placeholder="Enter your email address" placeholder="Enter your email address"
required required
/> />
</div>
<button type="submit" class="unsubscribe-btn">Unsubscribe</button> <button type="submit" class="unsubscribe-btn">Unsubscribe</button>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unsubscription Confirmed</title>
<link rel="stylesheet" href="/static/css/styles.css" />
<style>
.confirmation-container {
text-align: center;
}
.icon {
font-size: 48px;
color: #28a745;
margin-bottom: 20px;
}
.back-link {
margin-top: 30px;
}
</style>
</head>
<body>
<div class="confirmation-container">
<div class="icon"></div>
<h1>Unsubscription Confirmed</h1>
<p>
You have been successfully unsubscribed from our newsletter. We're sorry
to see you go, but you can always subscribe again if you change your
mind.
</p>
<p>
If you unsubscribed by mistake or have any questions, please feel free
to contact us.
</p>
<a href="/" class="back-link">Return to Homepage</a>
</div>
</body>
</html>

15
test_embed.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Embed</title>
</head>
<body>
<h1>Test Embedding Contact Form</h1>
<iframe src="http://localhost:5001/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Embed Newsletter</title>
</head>
<body>
<h1>Test Embedding Newsletter Subscription Form</h1>
<iframe src="http://localhost:5001/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
</body>
</html>

View File

@@ -76,13 +76,13 @@ def test_newsletter_update_email_not_found(client):
def test_newsletter_manage_page_get(client): def test_newsletter_manage_page_get(client):
resp = client.get("/api/newsletter/manage") resp = client.get("/newsletter/manage")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Newsletter Subscription Management" in resp.data assert b"Newsletter Subscription Management" in resp.data
def test_newsletter_manage_subscribe(client): def test_newsletter_manage_subscribe(client):
resp = client.post("/api/newsletter/manage", resp = client.post("/newsletter/manage",
data={"email": "manage@example.com", "action": "subscribe"}) data={"email": "manage@example.com", "action": "subscribe"})
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Successfully subscribed" in resp.data assert b"Successfully subscribed" in resp.data
@@ -90,29 +90,29 @@ def test_newsletter_manage_subscribe(client):
def test_newsletter_manage_unsubscribe(client): def test_newsletter_manage_unsubscribe(client):
# Subscribe first # Subscribe first
client.post("/api/newsletter/manage", client.post("/newsletter/manage",
data={"email": "manage@example.com", "action": "subscribe"}) data={"email": "manage@example.com", "action": "subscribe"})
# Unsubscribe # Unsubscribe
resp = client.post("/api/newsletter/manage", resp = client.post("/newsletter/manage",
data={"email": "manage@example.com", "action": "unsubscribe"}) data={"email": "manage@example.com", "action": "unsubscribe"})
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Successfully unsubscribed" in resp.data assert b"Unsubscription Confirmed" in resp.data
def test_newsletter_manage_update(client): def test_newsletter_manage_update(client):
# Subscribe first # Subscribe first
client.post("/api/newsletter/manage", client.post("/newsletter/manage",
data={"email": "old@example.com", "action": "subscribe"}) data={"email": "old@example.com", "action": "subscribe"})
# Update # Update
resp = client.post("/api/newsletter/manage", data={ resp = client.post("/newsletter/manage", data={
"old_email": "old@example.com", "new_email": "updated@example.com", "action": "update"}) "old_email": "old@example.com", "email": "updated@example.com", "action": "update"})
assert resp.status_code == 200 assert resp.status_code == 200
# Check that some success message is displayed # Check that some success message is displayed
assert b"success" in resp.data.lower() or b"updated" in resp.data.lower() assert b"updated successfully" in resp.data.lower()
def test_newsletter_manage_invalid_email(client): def test_newsletter_manage_invalid_email(client):
resp = client.post("/api/newsletter/manage", resp = client.post("/newsletter/manage",
data={"email": "invalid-email", "action": "subscribe"}) data={"email": "invalid-email", "action": "subscribe"})
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Please enter a valid email address" in resp.data assert b"Please enter a valid email address" in resp.data