From 56840ac31343caac9f62a57f601e5d2c9571c67d Mon Sep 17 00:00:00 2001 From: zwitschi Date: Thu, 30 Oct 2025 12:38:26 +0100 Subject: [PATCH] feat(newsletter): Add subscription confirmation email functionality - 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. --- README.md | 48 +++- iframe_contact.html | 1 + iframe_newsletter.html | 1 + server/routes/__init__.py | 3 +- server/routes/admin.py | 7 + server/routes/embed.py | 26 ++ server/routes/newsletter.py | 23 +- server/services/newsletter.py | 55 ++++ server/settings.py | 11 + static/css/styles.css | 335 ++++++++++++++++++++++++ templates/admin_dashboard.html | 89 +------ templates/admin_embeds.html | 112 ++++++++ templates/admin_newsletter.html | 3 +- templates/admin_newsletter_create.html | 3 +- templates/admin_settings.html | 169 +++--------- templates/admin_submissions.html | 136 +--------- templates/embed_contact.html | 60 +++++ templates/embed_newsletter.html | 55 ++++ templates/login.html | 38 ++- templates/newsletter_manage.html | 83 ++---- templates/unsubscribe_confirmation.html | 38 +++ test_embed.html | 15 ++ test_embed_newsletter.html | 15 ++ tests/test_newsletter_api.py | 20 +- 24 files changed, 897 insertions(+), 449 deletions(-) create mode 100644 iframe_contact.html create mode 100644 iframe_newsletter.html create mode 100644 server/routes/embed.py create mode 100644 static/css/styles.css create mode 100644 templates/admin_embeds.html create mode 100644 templates/embed_contact.html create mode 100644 templates/embed_newsletter.html create mode 100644 templates/unsubscribe_confirmation.html create mode 100644 test_embed.html create mode 100644 test_embed_newsletter.html diff --git a/README.md b/README.md index e35a0cb..8858f9f 100644 --- a/README.md +++ b/README.md @@ -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 a specific contact submission by ID (admin only). - `DELETE /api/contact/`: 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. - `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. - `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 /metrics`: Prometheus-compatible metrics endpoint (requires `ENABLE_REQUEST_LOGS` for detailed tracing). - `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//send`: sends a newsletter to all subscribers (admin only). - `GET /admin/api/contact`: retrieves contact form submissions with pagination, filtering, and sorting (admin only). - `DELETE /admin/api/contact/`: 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 + +``` + +To embed the newsletter subscription form: + +```html + +``` + +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 @@ -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. - 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. + +## 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 diff --git a/iframe_contact.html b/iframe_contact.html new file mode 100644 index 0000000..a3e10e8 --- /dev/null +++ b/iframe_contact.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/iframe_newsletter.html b/iframe_newsletter.html new file mode 100644 index 0000000..ae8d3b1 --- /dev/null +++ b/iframe_newsletter.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/routes/__init__.py b/server/routes/__init__.py index cd15602..9649e7f 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, monitoring, newsletter +from . import admin, auth, contact, embed, monitoring, newsletter def register_blueprints(app: Flask) -> None: @@ -13,3 +13,4 @@ def register_blueprints(app: Flask) -> None: app.register_blueprint(monitoring.bp) app.register_blueprint(auth.bp) app.register_blueprint(admin.bp) + app.register_blueprint(embed.bp) diff --git a/server/routes/admin.py b/server/routes/admin.py index 75eeaeb..0c82225 100644 --- a/server/routes/admin.py +++ b/server/routes/admin.py @@ -82,6 +82,13 @@ def submissions(): 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"]) @auth.login_required def get_settings_api(): diff --git a/server/routes/embed.py b/server/routes/embed.py new file mode 100644 index 0000000..0adad82 --- /dev/null +++ b/server/routes/embed.py @@ -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') diff --git a/server/routes/newsletter.py b/server/routes/newsletter.py index 24f9224..8dad6c0 100644 --- a/server/routes/newsletter.py +++ b/server/routes/newsletter.py @@ -7,10 +7,10 @@ from flask import Blueprint, jsonify, request, render_template 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(): payload = request.form or request.get_json(silent=True) or {} email = (payload.get("email") or "").strip() @@ -29,10 +29,16 @@ def subscribe(): return jsonify({"status": "error", "message": "Email is already subscribed."}), 409 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 -@bp.route("/newsletter", methods=["DELETE"]) +@bp.route("/api/newsletter", methods=["DELETE"]) def unsubscribe(): payload = request.form or request.get_json(silent=True) or {} email = (payload.get("email") or "").strip() @@ -55,7 +61,7 @@ def unsubscribe(): return jsonify({"status": "ok", "message": "Unsubscribed successfully."}), 200 -@bp.route("/newsletter", methods=["PUT"]) +@bp.route("/api/newsletter", methods=["PUT"]) def update_subscription(): payload = request.form or request.get_json(silent=True) or {} old_email = (payload.get("old_email") or "").strip() @@ -104,8 +110,7 @@ def manage_subscription(): elif action == "unsubscribe": deleted = newsletter.unsubscribe(email) if deleted: - message = "Successfully unsubscribed from newsletter." - message_type = "success" + return render_template("unsubscribe_confirmation.html") else: message = "This email is not currently subscribed." message_type = "info" @@ -131,3 +136,9 @@ def manage_subscription(): message_type = "error" 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") diff --git a/server/services/newsletter.py b/server/services/newsletter.py index 1ef77b3..a3a48d4 100644 --- a/server/services/newsletter.py +++ b/server/services/newsletter.py @@ -28,6 +28,61 @@ def update_email(old_email: str, new_email: str) -> bool: 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', """ + + +

Welcome to our Newsletter!

+

Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.

+

If you wish to unsubscribe at any time, you can do so by visiting our subscription management page.

+

Best regards,
The Team

+ + + """).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: """Send newsletter to list of email addresses. Returns count of successful sends.""" import logging diff --git a/server/settings.py b/server/settings.py index 353f05b..55fb614 100644 --- a/server/settings.py +++ b/server/settings.py @@ -63,3 +63,14 @@ if not SMTP_SETTINGS["sender"] and SMTP_SETTINGS["username"]: ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin") ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") + +NEWSLETTER_CONFIRMATION_TEMPLATE = os.getenv("NEWSLETTER_CONFIRMATION_TEMPLATE", """ + + +

Welcome to our Newsletter!

+

Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.

+

If you wish to unsubscribe at any time, you can do so by visiting our subscription management page.

+

Best regards,
The Team

+ + +""").strip() diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..3e89e9d --- /dev/null +++ b/static/css/styles.css @@ -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; +} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 09a6c3a..670d3d7 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -4,91 +4,13 @@ Admin Dashboard + @@ -138,6 +60,15 @@

Create and send newsletters to your subscribers.

Create Newsletter + +
+

Embeddable Forms

+

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

+ View Embed Codes +
diff --git a/templates/admin_embeds.html b/templates/admin_embeds.html new file mode 100644 index 0000000..ccd94ac --- /dev/null +++ b/templates/admin_embeds.html @@ -0,0 +1,112 @@ + + + + + + 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 index 1ba4904..9d7cb50 100644 --- a/templates/admin_newsletter.html +++ b/templates/admin_newsletter.html @@ -4,10 +4,9 @@ Newsletter Subscribers + @@ -146,6 +32,11 @@ style="color: #007bff; text-decoration: none; margin-right: 20px" >View Submissions + Embeds
+ + +
+

Email Templates

+
+

Loading email templates...

+
+
+ + + diff --git a/templates/embed_newsletter.html b/templates/embed_newsletter.html new file mode 100644 index 0000000..5d77568 --- /dev/null +++ b/templates/embed_newsletter.html @@ -0,0 +1,55 @@ + + + + + + Newsletter Subscription + + + +
+

Subscribe to Our Newsletter

+
+
+ + +
+ +
+
+
+ + + + diff --git a/templates/login.html b/templates/login.html index 57f5641..6009f37 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,27 +4,14 @@ Admin Login + + + +
+
+

Unsubscription Confirmed

+

+ 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. +

+

+ If you unsubscribed by mistake or have any questions, please feel free + to contact us. +

+ Return to Homepage +
+ + diff --git a/test_embed.html b/test_embed.html new file mode 100644 index 0000000..ed90a72 --- /dev/null +++ b/test_embed.html @@ -0,0 +1,15 @@ + + + + + + + Test Embed + + + +

Test Embedding Contact Form

+ + + + \ No newline at end of file diff --git a/test_embed_newsletter.html b/test_embed_newsletter.html new file mode 100644 index 0000000..78473db --- /dev/null +++ b/test_embed_newsletter.html @@ -0,0 +1,15 @@ + + + + + + + Test Embed Newsletter + + + +

Test Embedding Newsletter Subscription Form

+ + + + \ No newline at end of file diff --git a/tests/test_newsletter_api.py b/tests/test_newsletter_api.py index a4fa697..897154b 100644 --- a/tests/test_newsletter_api.py +++ b/tests/test_newsletter_api.py @@ -76,13 +76,13 @@ def test_newsletter_update_email_not_found(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 b"Newsletter Subscription Management" in resp.data def test_newsletter_manage_subscribe(client): - resp = client.post("/api/newsletter/manage", + resp = client.post("/newsletter/manage", data={"email": "manage@example.com", "action": "subscribe"}) assert resp.status_code == 200 assert b"Successfully subscribed" in resp.data @@ -90,29 +90,29 @@ def test_newsletter_manage_subscribe(client): def test_newsletter_manage_unsubscribe(client): # Subscribe first - client.post("/api/newsletter/manage", + client.post("/newsletter/manage", data={"email": "manage@example.com", "action": "subscribe"}) # Unsubscribe - resp = client.post("/api/newsletter/manage", + resp = client.post("/newsletter/manage", data={"email": "manage@example.com", "action": "unsubscribe"}) 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): # Subscribe first - client.post("/api/newsletter/manage", + client.post("/newsletter/manage", data={"email": "old@example.com", "action": "subscribe"}) # Update - resp = client.post("/api/newsletter/manage", data={ - "old_email": "old@example.com", "new_email": "updated@example.com", "action": "update"}) + resp = client.post("/newsletter/manage", data={ + "old_email": "old@example.com", "email": "updated@example.com", "action": "update"}) assert resp.status_code == 200 # 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): - resp = client.post("/api/newsletter/manage", + resp = client.post("/newsletter/manage", data={"email": "invalid-email", "action": "subscribe"}) assert resp.status_code == 200 assert b"Please enter a valid email address" in resp.data