Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:5173
# Keep this false for local/dev/staging environments.
SEARCH_ENGINE_INDEXING_ENABLED=false

# Feedback form email delivery. In Kamal production, sendmail is provided by
# msmtp-mta and relays through smtp.umn.edu. Use FEEDBACK_RECIPIENTS to route
# public feedback to the Geoportal team.
FEEDBACK_EMAIL_ENABLED=true
FEEDBACK_RECIPIENTS="ewlarson@gmail.com,majew030@umn.edu"
FEEDBACK_FROM="BTAA Geoportal <no-reply@geo.btaa.org>"
FEEDBACK_DELIVERY=sendmail

# Old Database
OLD_DB_NAME=geoportal_production_20251030

Expand Down
90 changes: 90 additions & 0 deletions backend/app/api/v1/endpoint_modules/feedback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import logging
import re
from typing import Any

from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field, field_validator

from app.services.feedback_service import (
FEEDBACK_TOPICS,
FeedbackDeliveryUnavailable,
FeedbackSubmission,
send_feedback_email,
)

logger = logging.getLogger(__name__)
router = APIRouter()


class FeedbackRequest(BaseModel):
name: str = Field(default="", max_length=120)
email_address: str = Field(default="", max_length=254)
topic: str = Field(..., min_length=1, max_length=80)
description: str = Field(..., min_length=1, max_length=5000)
contact_info: str = Field(default="", max_length=500)
source_url: str = Field(default="", max_length=1000)
user_agent: str = Field(default="", max_length=1000)

@field_validator("*", mode="before")
@classmethod
def strip_string_fields(cls, value: Any) -> Any:
if isinstance(value, str):
return value.strip()
return value

@field_validator("topic")
@classmethod
def topic_must_be_known(cls, value: str) -> str:
if value not in FEEDBACK_TOPICS:
raise ValueError("Select a feedback topic.")
return value

@field_validator("email_address")
@classmethod
def email_must_be_valid_when_present(cls, value: str) -> str:
if value and not re.match(r"^[^\s@]+@[^\s@]+\.[^\s@]+$", value):
raise ValueError("Enter a valid email address.")
return value


@router.post("/feedback", include_in_schema=False)
async def submit_feedback(payload: FeedbackRequest, request: Request):
submission = FeedbackSubmission(
name=payload.name,
email_address=payload.email_address,
topic=payload.topic,
description=payload.description,
contact_info=payload.contact_info,
source_url=payload.source_url or request.headers.get("referer", ""),
user_agent=payload.user_agent or request.headers.get("user-agent", ""),
)

try:
result = send_feedback_email(submission)
except FeedbackDeliveryUnavailable as exc:
logger.warning("Feedback delivery unavailable: %s", exc)
return JSONResponse(
status_code=503,
content={"message": "Feedback delivery is temporarily unavailable."},
)
except Exception:
logger.exception("Unexpected feedback delivery failure")
return JSONResponse(
status_code=503,
content={"message": "Feedback delivery is temporarily unavailable."},
)

return JSONResponse(
status_code=202,
content={
"data": {
"type": "feedback-submission",
"id": "submitted",
"attributes": {
"accepted": True,
"sent": bool(result.get("sent")),
},
}
},
)
1 change: 1 addition & 0 deletions backend/app/api/v1/endpoint_modules/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def api_root(request: Request):
),
"endpoints": [
"/api/v1/",
"/api/v1/feedback",
"/api/v1/home/blog-posts",
"/api/v1/search",
"/api/v1/search/facets/{facet_name}",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/v1/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .endpoint_modules.admin import router as admin_router
from .endpoint_modules.analytics import router as analytics_router
from .endpoint_modules.feedback import router as feedback_router
from .endpoint_modules.gazetteer import router as gazetteer_router
from .endpoint_modules.home import router as home_router
from .endpoint_modules.map import router as map_router
Expand All @@ -29,6 +30,7 @@
router.include_router(root_router, tags=["root"])
router.include_router(search_router, tags=["search"])
router.include_router(analytics_router, tags=["analytics"], include_in_schema=False)
router.include_router(feedback_router, tags=["feedback"], include_in_schema=False)
router.include_router(home_router, tags=["home"])
router.include_router(resources_router, tags=["resources"])
router.include_router(thumbnails_router, tags=["thumbnails"])
Expand Down
159 changes: 159 additions & 0 deletions backend/app/services/feedback_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from __future__ import annotations

import os
import re
import smtplib
import subprocess
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr

FEEDBACK_TOPICS = {
"Correction",
"Question",
"Comments or Suggestions",
"Harmful language",
"Other",
}
DEFAULT_FEEDBACK_RECIPIENTS = "majew030@umn.edu,btaa-gdp@umn.edu,geoportal@btaa.org"


class FeedbackDeliveryUnavailable(RuntimeError):
"""Raised when feedback mail cannot be delivered with current configuration."""


@dataclass(frozen=True)
class FeedbackSubmission:
name: str
email_address: str
topic: str
description: str
source_url: str = ""
user_agent: str = ""
contact_info: str = ""


def _env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}


def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, str(default)))
except (TypeError, ValueError):
return default


def _split_recipients(value: str | None) -> list[str]:
if not value:
return []
return [part.strip() for part in re.split(r"[,;\n]", value) if part.strip()]


def _feedback_delivery() -> str:
configured = os.getenv("FEEDBACK_DELIVERY") or os.getenv("BRIDGE_SYNC_REPORT_DELIVERY")
if configured:
return configured.strip().lower()
return "sendmail" if os.path.exists("/usr/sbin/sendmail") else "smtp"


def _sender() -> str:
sender = os.getenv("FEEDBACK_FROM") or os.getenv("SMTP_FROM")
if sender:
return sender
return formataddr(("BTAA Geoportal", "no-reply@geo.btaa.org"))


def _subject(topic: str) -> str:
prefix = os.getenv("FEEDBACK_SUBJECT_PREFIX", "BTAA Geoportal Feedback")
return f"{prefix}: {topic}"


def _build_message(submission: FeedbackSubmission, recipients: list[str]) -> EmailMessage:
message = EmailMessage()
message["Subject"] = _subject(submission.topic)
message["From"] = _sender()
message["To"] = ", ".join(recipients)

if submission.email_address:
reply_name = submission.name or submission.email_address
message["Reply-To"] = formataddr((reply_name, submission.email_address))

submitted_by = submission.name or "Not provided"
submitted_email = submission.email_address or "Not provided"
source_url = submission.source_url or "Not provided"
user_agent = submission.user_agent or "Not provided"

message.set_content(
"\n".join(
[
"A BTAA Geoportal feedback form was submitted.",
"",
f"Topic: {submission.topic}",
f"Name: {submitted_by}",
f"Email: {submitted_email}",
f"Source URL: {source_url}",
f"User-Agent: {user_agent}",
"",
"Description:",
submission.description,
]
)
)
return message


def send_feedback_email(submission: FeedbackSubmission) -> dict:
if not _env_bool("FEEDBACK_EMAIL_ENABLED", True):
raise FeedbackDeliveryUnavailable("feedback_email_disabled")

if submission.contact_info.strip():
return {"sent": False, "reason": "honeypot"}

recipients = _split_recipients(os.getenv("FEEDBACK_RECIPIENTS", DEFAULT_FEEDBACK_RECIPIENTS))
if not recipients:
raise FeedbackDeliveryUnavailable("no_feedback_recipients")

delivery = _feedback_delivery()
message = _build_message(submission, recipients)

if delivery == "sendmail":
sendmail_path = os.getenv("SENDMAIL_PATH", "/usr/sbin/sendmail")
sendmail_args = os.getenv("SENDMAIL_ARGS", "-t -i").split()
try:
subprocess.run(
[sendmail_path, *sendmail_args],
input=message.as_bytes(),
check=True,
timeout=_env_int("SENDMAIL_TIMEOUT_SECONDS", 20),
)
except (OSError, subprocess.SubprocessError) as exc:
raise FeedbackDeliveryUnavailable("sendmail_failed") from exc
return {"sent": True, "delivery": "sendmail", "recipients": len(recipients)}

host = os.getenv("SMTP_HOST")
if not host:
raise FeedbackDeliveryUnavailable("no_smtp_host")

port = _env_int("SMTP_PORT", 587)
timeout = _env_int("SMTP_TIMEOUT_SECONDS", 20)
username = os.getenv("SMTP_USERNAME")
password = os.getenv("SMTP_PASSWORD")
use_ssl = _env_bool("SMTP_SSL", False)
use_starttls = _env_bool("SMTP_STARTTLS", not use_ssl)

smtp_cls = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
try:
with smtp_cls(host, port, timeout=timeout) as smtp:
if use_starttls and not use_ssl:
smtp.starttls()
if username and password:
smtp.login(username, password)
smtp.send_message(message)
except OSError as exc:
raise FeedbackDeliveryUnavailable("smtp_failed") from exc

return {"sent": True, "delivery": "smtp", "recipients": len(recipients)}
7 changes: 3 additions & 4 deletions backend/templates/docs.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ <h1><a class="navbar-brand" href="/api/docs">BTAA Geospatial API</a></h1>
<div class="collapse navbar-collapse justify-content-md-end" id="user-util-collapse">
<ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="https://gin.btaa.org/api">API Documentation</a></li>
<li class="nav-item"><a class="nav-link" href="https://geo.btaa.org/feedback">Feedback</a></li>
<li class="nav-item"><a class="nav-link" href="/feedback">Feedback</a></li>
</ul>
</div>
</nav>
Expand All @@ -33,7 +33,7 @@ <h1><a class="navbar-brand" href="/api/docs">BTAA Geospatial API</a></h1>

<div class="admonition warning">
<p class="admonition-title">DRAFT Specification / Work in Progress</p>
<p class="admonition-content">This portion of the BTAA GIN site, our BTAA Geospatial API, and our linked data offerings are a <strong><em>WORK IN PROGRESS</em></strong>. Please <a href="https://geo.btaa.org/feedback">reach out</a> if you have questions or wish to participate in bringing these resources to public release.</p>
<p class="admonition-content">This portion of the BTAA GIN site, our BTAA Geospatial API, and our linked data offerings are a <strong><em>WORK IN PROGRESS</em></strong>. Please <a href="/feedback">reach out</a> if you have questions or wish to participate in bringing these resources to public release.</p>
</div>

<div id="swagger-ui"></div>
Expand All @@ -58,7 +58,7 @@ <h3 class="mt-3">About &amp; Help</h3>
<a href="https://gin.btaa.org/updates">Program Updates</a>
</li>
<li>
<a href="https://geo.btaa.org/feedback">Contact Us</a>
<a href="/feedback">Contact Us</a>
</li>
<li>
<a href="https://gin.btaa.org/guides/">Help Guides</a>
Expand Down Expand Up @@ -181,4 +181,3 @@ <h4 class="mt-4">BTAA Member Libraries</h4>
</script>
</body>
</html>

Loading
Loading