diff --git a/.env.example b/.env.example index 117630d..b9c58ed 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,16 @@ MYSQL_PASSWORD=visione MYSQL_ROOT_PASSWORD=root MAILPIT_SMTP_PORT=1025 MAILPIT_WEB_PORT=8025 -MAIL_PROVIDER=resend -RESEND_API_BASE_URL=https://api.resend.com -RESEND_API_KEY= + +MAIL_PROVIDER=smtp +SMTP_HOST=127.0.0.1 +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_USE_TLS=false +SMTP_USE_STARTTLS=false +SMTP_VERIFY_PEER=false +SMTP_VERIFY_HOST=false + MAIL_FROM=noreply@visionebari.it CORS_ALLOWED_ORIGIN=http://localhost:3000 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a15036..6b94958 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) find_package(Python3 COMPONENTS Interpreter REQUIRED) find_package(OpenSSL REQUIRED) +find_package(CURL REQUIRED) option(USE_SYSTEM_DROGON "Use an installed Drogon package instead of CPM" OFF) @@ -72,6 +73,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PRIVATE Drogon::Drogon + CURL::libcurl OpenSSL::Crypto ) diff --git a/Dockerfile b/Dockerfile index 25be695..b09d8a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential ccache cmake ninja-build git pkg-config \ - libssl-dev zlib1g-dev \ + libcurl4-openssl-dev libssl-dev zlib1g-dev \ libmariadb-dev libmariadb-dev-compat \ libsqlite3-dev python3 libjsoncpp-dev uuid-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* @@ -27,7 +27,7 @@ FROM ubuntu:22.04 AS runtime ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ - libssl3 ca-certificates libstdc++6 libmariadb3 libjsoncpp25 libuuid1 \ + libcurl4 libssl3 ca-certificates libstdc++6 libmariadb3 libjsoncpp25 libuuid1 \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/README.md b/README.md index 9f74a44..0380e53 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Install native build dependencies first. On Ubuntu: ```bash sudo apt install build-essential cmake ninja-build git pkg-config \ - libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \ + libcurl4-openssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libssl-dev \ libmariadb-dev libmariadb-dev-compat ``` @@ -95,18 +95,29 @@ The app serves the generated JSON at `/api/v1/openapi.json` and Swagger UI at ## Email Delivery -OTP delivery uses Resend HTTP API through Drogon's async `HttpClient`. -Set these environment variables before starting backend: +OTP delivery uses SMTP through `libcurl`. Local dev defaults to Mailpit. +Set these environment variables before starting backend for local Mailpit: ```bash -export MAIL_PROVIDER=resend -export RESEND_API_BASE_URL=https://api.resend.com -export RESEND_API_KEY=re_xxx +export MAIL_PROVIDER=smtp +export SMTP_HOST=127.0.0.1 +export SMTP_PORT=1025 export MAIL_FROM=noreply@visionebari.it ``` -Mailpit can still run locally for manual inspection or future integrations, but -current backend OTP flow no longer speaks SMTP directly. +For Resend in production, use SMTP credentials instead: + +```bash +export MAIL_PROVIDER=resend +export SMTP_HOST=smtp.resend.com +export SMTP_PORT=465 +export SMTP_USERNAME=resend +export SMTP_PASSWORD=re_xxx +export SMTP_USE_TLS=true +export SMTP_VERIFY_PEER=true +export SMTP_VERIFY_HOST=true +export MAIL_FROM=noreply@visionebari.it +``` ## Visione Lab @@ -157,7 +168,7 @@ multi-architecture Mailpit image to avoid `exec format error` failures on ARM servers. Set `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `MYSQL_ROOT_PASSWORD`, -`RESEND_API_KEY`, `MAIL_FROM` and +`SMTP_PASSWORD`, `MAIL_FROM` and `CORS_ALLOWED_ORIGIN` in Dokploy environment variables. Use the exact browser origin without quotes or a trailing slash, for example `https://visione.studentindipendenti.it`. diff --git a/common/Email.cc b/common/Email.cc index 4488489..9c5c01c 100644 --- a/common/Email.cc +++ b/common/Email.cc @@ -1,74 +1,239 @@ #include "common/Email.h" +#include #include -#include +#include +#include +#include +#include +#include namespace visione::email { namespace { +const Json::Value &mailConfig() +{ + return drogon::app().getCustomConfig()["mail"]; +} + std::string configString(const std::string &key, const std::string &fallback) { - const auto &mail = drogon::app().getCustomConfig()["mail"]; + const auto &mail = mailConfig(); return mail.get(key, fallback).asString(); } + +int configInt(const std::string &key, int fallback) +{ + const auto &mail = mailConfig(); + return mail.get(key, fallback).asInt(); +} + +bool configBool(const std::string &key, bool fallback) +{ + const auto &mail = mailConfig(); + return mail.get(key, fallback).asBool(); +} + +struct SmtpDefaults +{ + std::string host; + int port; + std::string username; + bool useTls; + bool useStartTls; + bool verifyPeer; + bool verifyHost; +}; + +SmtpDefaults smtpDefaults(const std::string &provider) +{ + if (provider == "resend") + { + return { + .host = "smtp.resend.com", + .port = 465, + .username = "resend", + .useTls = true, + .useStartTls = false, + .verifyPeer = true, + .verifyHost = true, + }; + } + + return { + .host = "127.0.0.1", + .port = 1025, + .username = "", + .useTls = false, + .useStartTls = false, + .verifyPeer = false, + .verifyHost = false, + }; +} + +std::string smtpUrl(const std::string &host, int port, bool useTls) +{ + return (useTls ? "smtps://" : "smtp://") + host + ":" + std::to_string(port); +} + +struct UploadContext +{ + const std::string *payload; + size_t offset{0}; +}; + +size_t readPayload(char *buffer, size_t size, size_t count, void *userData) +{ + auto *context = static_cast(userData); + const auto capacity = size * count; + const auto remaining = context->payload->size() - context->offset; + const auto bytesToCopy = std::min(capacity, remaining); + + if (bytesToCopy == 0) + { + return 0; + } + + std::memcpy(buffer, + context->payload->data() + static_cast(context->offset), + bytesToCopy); + context->offset += bytesToCopy; + return bytesToCopy; +} + +bool ensureCurlInitialized() +{ + static const bool initialized = []() { + const auto result = curl_global_init(CURL_GLOBAL_DEFAULT); + if (result != CURLE_OK) + { + LOG_ERROR << "curl_global_init failed: " << curl_easy_strerror(result); + return false; + } + return true; + }(); + return initialized; +} } // namespace std::string EmailService::deliveryName() const { - return configString("provider", "resend"); + return configString("provider", "smtp"); } bool EmailService::sendOtp(const std::string &to, const std::string &code) const { - const auto apiBaseUrl = configString("api_base_url", "https://api.resend.com"); - const auto apiKey = configString("api_key", ""); + const auto provider = deliveryName(); + const auto defaults = smtpDefaults(provider); + const auto host = configString("host", defaults.host); + const auto port = configInt("port", defaults.port); + const auto username = configString("username", defaults.username); + const auto password = configString("password", ""); const auto from = configString("from", ""); + const auto useTls = configBool("use_tls", defaults.useTls); + const auto useStartTls = configBool("use_starttls", defaults.useStartTls); + const auto verifyPeer = configBool("verify_peer", defaults.verifyPeer); + const auto verifyHost = configBool("verify_host", defaults.verifyHost); - if (apiKey.empty() || from.empty()) + if (host.empty() || from.empty()) { - LOG_ERROR << "Email delivery configuration missing: api_key or from"; + LOG_ERROR << "Email delivery configuration missing: host or from"; + return false; + } + if (provider == "resend" && password.empty()) + { + LOG_ERROR << "Email delivery configuration missing: SMTP password for Resend"; return false; } - auto client = drogon::HttpClient::newHttpClient(apiBaseUrl); + if (!ensureCurlInitialized()) + { + return false; + } - Json::Value payload; - payload["from"] = "Visione <" + from + ">"; - payload["subject"] = "Codice OTP Visione"; - payload["text"] = "Il tuo codice OTP per Visione e': " + code + - "\nScade tra 10 minuti.\n"; - payload["to"] = Json::arrayValue; - payload["to"].append(to); + std::ostringstream payload; + payload << "To: <" << to << ">\r\n"; + payload << "From: Visione <" << from << ">\r\n"; + payload << "Subject: Codice OTP Visione\r\n"; + payload << "MIME-Version: 1.0\r\n"; + payload << "Content-Type: text/plain; charset=utf-8\r\n"; + payload << "\r\n"; + payload << "Il tuo codice OTP per Visione e': " << code << "\r\n"; + payload << "Scade tra 10 minuti.\r\n"; + const auto payloadString = payload.str(); + const auto url = smtpUrl(host, port, useTls); + const auto mailFrom = "<" + from + ">"; + const auto recipientAddress = "<" + to + ">"; - auto request = drogon::HttpRequest::newHttpJsonRequest(payload); - request->setMethod(drogon::Post); - request->setPath("/emails"); - request->addHeader("Authorization", "Bearer " + apiKey); - request->addHeader("User-Agent", "visione-backend/1.0"); + using CurlHandle = std::unique_ptr; + using CurlList = std::unique_ptr; - client->sendRequest( - request, - [recipient = to](drogon::ReqResult result, const drogon::HttpResponsePtr &response) { - if (result == drogon::ReqResult::Ok && - response != nullptr && - response->statusCode() == drogon::k200OK) - { - LOG_INFO << "OTP email queued via Resend for " << recipient; - return; - } + CurlHandle curl(curl_easy_init(), &curl_easy_cleanup); + if (!curl) + { + LOG_ERROR << "Could not initialize libcurl SMTP handle"; + return false; + } - const auto body = response != nullptr ? response->getBody() : std::string{}; - LOG_ERROR << "OTP email delivery failed via Resend for " - << recipient - << ", result=" << static_cast(result) - << ", status=" - << (response != nullptr ? std::to_string(response->statusCode()) - : "none") - << ", body=" << body; - }); + CurlList recipients(nullptr, &curl_slist_free_all); + recipients.reset(curl_slist_append(nullptr, recipientAddress.c_str())); + if (!recipients) + { + LOG_ERROR << "Could not build SMTP recipient list for " << to; + return false; + } + UploadContext uploadContext{.payload = &payloadString}; + char errorBuffer[CURL_ERROR_SIZE] = {0}; + + curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_MAIL_FROM, mailFrom.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_MAIL_RCPT, recipients.get()); + curl_easy_setopt(curl.get(), CURLOPT_READFUNCTION, readPayload); + curl_easy_setopt(curl.get(), CURLOPT_READDATA, &uploadContext); + curl_easy_setopt(curl.get(), CURLOPT_UPLOAD, 1L); + curl_easy_setopt(curl.get(), CURLOPT_USERAGENT, "visione-backend/1.0"); + curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT, 10L); + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 20L); + curl_easy_setopt(curl.get(), CURLOPT_ERRORBUFFER, errorBuffer); + curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYPEER, verifyPeer ? 1L : 0L); + curl_easy_setopt(curl.get(), CURLOPT_SSL_VERIFYHOST, verifyHost ? 2L : 0L); + + if (!username.empty()) + { + curl_easy_setopt(curl.get(), CURLOPT_USERNAME, username.c_str()); + } + if (!password.empty()) + { + curl_easy_setopt(curl.get(), CURLOPT_PASSWORD, password.c_str()); + } + if (useStartTls) + { + curl_easy_setopt(curl.get(), CURLOPT_USE_SSL, CURLUSESSL_ALL); + } + + const auto result = curl_easy_perform(curl.get()); + if (result != CURLE_OK) + { + LOG_ERROR << "OTP email delivery failed via SMTP for " + << to + << ", provider=" + << provider + << ", host=" + << host + << ", port=" + << port + << ", error=" + << (errorBuffer[0] != '\0' ? errorBuffer : curl_easy_strerror(result)); + return false; + } + + LOG_INFO << "OTP email sent via SMTP for " << to + << ", provider=" << provider + << ", host=" << host + << ", port=" << port; return true; } } // namespace visione::email diff --git a/config.json b/config.json index 62f9ace..de4a8a9 100644 --- a/config.json +++ b/config.json @@ -64,10 +64,16 @@ "default_frontend_redirect_uri": "http://localhost:3000/auth/cie/callback" }, "mail": { - "provider": "resend", - "api_base_url": "https://api.resend.com", - "api_key": "", - "from": "noreply@visionebari.it" + "provider": "smtp", + "host": "127.0.0.1", + "port": 1025, + "username": "", + "password": "", + "from": "noreply@visionebari.it", + "use_tls": false, + "use_starttls": false, + "verify_peer": false, + "verify_host": false }, "uploads": { "public_base_path": "/uploads", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e016e70..a4e01e3 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,7 +14,14 @@ services: DB_NAME: ${DB_NAME:-visione} DB_TIMEOUT: ${DB_TIMEOUT:-30} MAIL_PROVIDER: ${MAIL_PROVIDER:-resend} - RESEND_API_BASE_URL: ${RESEND_API_BASE_URL:-https://api.resend.com} + SMTP_HOST: ${SMTP_HOST:-smtp.resend.com} + SMTP_PORT: ${SMTP_PORT:-465} + SMTP_USERNAME: ${SMTP_USERNAME:-resend} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_USE_TLS: ${SMTP_USE_TLS:-true} + SMTP_USE_STARTTLS: ${SMTP_USE_STARTTLS:-false} + SMTP_VERIFY_PEER: ${SMTP_VERIFY_PEER:-true} + SMTP_VERIFY_HOST: ${SMTP_VERIFY_HOST:-true} RESEND_API_KEY: ${RESEND_API_KEY:-} MAIL_FROM: ${MAIL_FROM:-noreply@visionebari.it} CORS_ALLOWED_ORIGIN: ${CORS_ALLOWED_ORIGIN:-http://localhost:3000} diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index 01c1032..ba6a587 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -32,7 +32,7 @@ Authorization: Bearer - `POST /auth/register` citizen registration without password - `POST /auth/otp/request` - queues OTP email through configured mail provider + sends OTP email through configured SMTP provider - `POST /auth/otp/verify` consumes OTP, returns bearer token - `POST /auth/cie/start` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c3127d..34c911f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,7 +24,7 @@ generate public read-only intervention links. ## Runtime Model - Auth uses bearer tokens from `api_tokens`. -- OTP login uses `otp_challenges` and async email delivery through configured mail provider. +- OTP login uses `otp_challenges` and SMTP email delivery through configured mail provider. - CIE login uses external Django RP service: - Visione creates local `state` - Django RP completes CIE/OIDC and sends signed identity payload to Visione diff --git a/docs/openapi/visione.openapi.yaml b/docs/openapi/visione.openapi.yaml index a37b64e..1f4e6da 100644 --- a/docs/openapi/visione.openapi.yaml +++ b/docs/openapi/visione.openapi.yaml @@ -2582,7 +2582,7 @@ }, "/api/v1/auth/otp/request": { "post": { - "description": "Queues a one-time code by email through configured mail provider.", + "description": "Sends a one-time code by email through configured SMTP provider.", "operationId": "AuthController.requestOtp", "parameters": [ ], "requestBody": { @@ -3312,7 +3312,7 @@ }, "/api/v1/reports": { "get": { - "description": "Lists reports visible to the authenticated user.", + "description": "Lists reports visible to the authenticated user. Citizens see own reports only; operators and admins can read all reports. Admin can use this endpoint for global read-only overview screens.", "operationId": "ReportController.list", "parameters": [ { diff --git a/main.cc b/main.cc index b105697..d8c20c5 100644 --- a/main.cc +++ b/main.cc @@ -5,8 +5,24 @@ #include "repositories/MigrationRunner.h" #include -#include #include +#include +#include + +namespace +{ +bool parseEnvBool(const char *value) +{ + if (value == nullptr) + { + return false; + } + + const std::string_view normalized(value); + return normalized == "1" || normalized == "true" || normalized == "TRUE" || + normalized == "yes" || normalized == "on"; +} +} // namespace int main() { @@ -19,6 +35,7 @@ int main() { configFile >> config; + // DB config from env if (const char* envHost = std::getenv("DB_HOST")) config["db_clients"][0]["host"] = envHost; if (const char* envPort = std::getenv("DB_PORT")) @@ -32,16 +49,35 @@ int main() if (const char* envTimeout = std::getenv("DB_TIMEOUT")) config["db_clients"][0]["timeout"] = std::stod(envTimeout); + // Mail config from env if (const char* envMailProvider = std::getenv("MAIL_PROVIDER")) config["custom_config"]["mail"]["provider"] = envMailProvider; - if (const char* envMailApiBaseUrl = std::getenv("RESEND_API_BASE_URL")) - config["custom_config"]["mail"]["api_base_url"] = envMailApiBaseUrl; - if (const char* envMailApiKey = std::getenv("RESEND_API_KEY")) - config["custom_config"]["mail"]["api_key"] = envMailApiKey; + if (const char* envSmtpHost = std::getenv("SMTP_HOST")) + config["custom_config"]["mail"]["host"] = envSmtpHost; + if (const char* envSmtpPort = std::getenv("SMTP_PORT")) + config["custom_config"]["mail"]["port"] = std::stoi(envSmtpPort); + if (const char* envSmtpUsername = std::getenv("SMTP_USERNAME")) + config["custom_config"]["mail"]["username"] = envSmtpUsername; + if (const char* envSmtpPassword = std::getenv("SMTP_PASSWORD")) + config["custom_config"]["mail"]["password"] = envSmtpPassword; + else if (const char* envResendApiKey = std::getenv("RESEND_API_KEY")) + config["custom_config"]["mail"]["password"] = envResendApiKey; + if (const char* envSmtpUseTls = std::getenv("SMTP_USE_TLS")) + config["custom_config"]["mail"]["use_tls"] = parseEnvBool(envSmtpUseTls); + if (const char* envSmtpUseStartTls = std::getenv("SMTP_USE_STARTTLS")) + config["custom_config"]["mail"]["use_starttls"] = parseEnvBool(envSmtpUseStartTls); + if (const char* envSmtpVerifyPeer = std::getenv("SMTP_VERIFY_PEER")) + config["custom_config"]["mail"]["verify_peer"] = parseEnvBool(envSmtpVerifyPeer); + if (const char* envSmtpVerifyHost = std::getenv("SMTP_VERIFY_HOST")) + config["custom_config"]["mail"]["verify_host"] = parseEnvBool(envSmtpVerifyHost); if (const char* envMailFrom = std::getenv("MAIL_FROM")) config["custom_config"]["mail"]["from"] = envMailFrom; + + // CORS config from env if (const char* envCorsAllowedOrigin = std::getenv("CORS_ALLOWED_ORIGIN")) config["custom_config"]["cors"]["allowed_origin"] = envCorsAllowedOrigin; + + // CIE auth config from env if (const char* envCieServiceBaseUrl = std::getenv("CIE_AUTH_SERVICE_BASE_URL")) config["custom_config"]["cie_auth"]["service_base_url"] = envCieServiceBaseUrl; if (const char* envCieStartPath = std::getenv("CIE_AUTH_START_PATH")) @@ -52,6 +88,7 @@ int main() config["custom_config"]["cie_auth"]["state_ttl_minutes"] = std::stoi(envCieStateTtl); if (const char* envCieFrontendRedirect = std::getenv("CIE_AUTH_DEFAULT_FRONTEND_REDIRECT_URI")) config["custom_config"]["cie_auth"]["default_frontend_redirect_uri"] = envCieFrontendRedirect; + drogon::app().loadConfigJson(config); } else diff --git a/services/AuthService.cc b/services/AuthService.cc index d374015..0cd7eb4 100644 --- a/services/AuthService.cc +++ b/services/AuthService.cc @@ -328,7 +328,7 @@ Json::Value AuthService::requestOtp(const Json::Value &body) email::EmailService mail; if (!mail.sendOtp(user[0]["email"].as(), code)) { - LOG_WARN << "Could not queue OTP email through " << mail.deliveryName(); + LOG_WARN << "Could not send OTP email through " << mail.deliveryName(); return failure("otp_email_failed"); }