From db833785135f082ab1ac728092083c143beb1e0b Mon Sep 17 00:00:00 2001 From: Vikramaditya Khupse <116285629+Phantom-VK@users.noreply.github.com> Date: Mon, 25 May 2026 14:41:33 +0530 Subject: [PATCH] Fix: replace SMTP with Resend HTTP API (port 587 blocked on Render free tier) --- visit-notifier/.env.example | 18 ++- .../portfolio/visitnotifier/AppConfig.kt | 33 +++++- .../portfolio/visitnotifier/Main.kt | 10 +- .../visitnotifier/ResendEmailSender.kt | 107 ++++++++++++++++++ 4 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/ResendEmailSender.kt diff --git a/visit-notifier/.env.example b/visit-notifier/.env.example index 3f88177..d424e9e 100644 --- a/visit-notifier/.env.example +++ b/visit-notifier/.env.example @@ -1,11 +1,19 @@ PORT=8787 ALLOWED_ORIGINS=https://,https:// -SMTP_HOST=smtp.gmail.com -SMTP_PORT=587 -SMTP_USERNAME=your-gmail-address@gmail.com -SMTP_APP_PASSWORD=your-app-password + +# Resend HTTP API key (https://resend.com) — replaces SMTP, works on Render free tier +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx + +# Must match a verified sender in your Resend account +VISIT_NOTIFY_FROM=notifications@yourdomain.com VISIT_NOTIFY_TO=you@example.com -VISIT_NOTIFY_FROM=your-gmail-address@gmail.com + +# SMTP fields below are no longer used in production (kept for local dev fallback reference) +# SMTP_HOST=smtp.gmail.com +# SMTP_PORT=587 +# SMTP_USERNAME=your-gmail@gmail.com +# SMTP_APP_PASSWORD=your-app-password + VISIT_RATE_LIMIT_WINDOW_SECONDS=60 VISIT_RATE_LIMIT_MAX_REQUESTS=30 VISIT_DEDUPE_TTL_SECONDS=900 diff --git a/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/AppConfig.kt b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/AppConfig.kt index 8061510..e444e87 100644 --- a/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/AppConfig.kt +++ b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/AppConfig.kt @@ -2,10 +2,14 @@ package com.vikramaditya.portfolio.visitnotifier import java.nio.file.Files import java.nio.file.Path +import java.util.logging.Logger data class AppConfig( val port: Int, val allowedOrigins: Set, + // Resend HTTP API (replaces SMTP — works on Render free tier) + val resendApiKey: String, + // SMTP fields kept for reference / local fallback logging but not used in production val smtpHost: String, val smtpPort: Int, val smtpUsername: String, @@ -18,19 +22,25 @@ data class AppConfig( val maxPayloadBytes: Int, ) { companion object { + private val logger = Logger.getLogger(AppConfig::class.java.name) + fun fromEnvironment(env: Map = System.getenv()): AppConfig { val mergedEnv = loadDotEnv() + env - return AppConfig( + + logger.info("config_loading port=${mergedEnv["PORT"]} allowed_origins=${mergedEnv["ALLOWED_ORIGINS"]}") + + val config = AppConfig( port = mergedEnv["PORT"]?.toIntOrNull() ?: 8787, allowedOrigins = mergedEnv.require("ALLOWED_ORIGINS") .split(",") .map { it.trim() } .filter { it.isNotEmpty() } .toSet(), + resendApiKey = mergedEnv.require("RESEND_API_KEY"), smtpHost = mergedEnv["SMTP_HOST"]?.ifBlank { null } ?: "smtp.gmail.com", smtpPort = mergedEnv["SMTP_PORT"]?.toIntOrNull() ?: 587, - smtpUsername = mergedEnv.require("SMTP_USERNAME"), - smtpAppPassword = mergedEnv.require("SMTP_APP_PASSWORD"), + smtpUsername = mergedEnv["SMTP_USERNAME"] ?: "", + smtpAppPassword = mergedEnv["SMTP_APP_PASSWORD"] ?: "", visitNotifyTo = mergedEnv.require("VISIT_NOTIFY_TO"), visitNotifyFrom = mergedEnv.require("VISIT_NOTIFY_FROM"), rateLimitWindowSeconds = mergedEnv["VISIT_RATE_LIMIT_WINDOW_SECONDS"]?.toLongOrNull() ?: 60L, @@ -38,6 +48,16 @@ data class AppConfig( dedupeTtlSeconds = mergedEnv["VISIT_DEDUPE_TTL_SECONDS"]?.toLongOrNull() ?: 900L, maxPayloadBytes = mergedEnv["VISIT_MAX_PAYLOAD_BYTES"]?.toIntOrNull() ?: 8_192, ) + + logger.info( + "config_loaded port=${config.port} " + + "allowed_origins=${config.allowedOrigins} " + + "notify_to=${config.visitNotifyTo} " + + "notify_from=${config.visitNotifyFrom} " + + "resend_key_set=${config.resendApiKey.isNotBlank()}" + ) + + return config } private fun loadDotEnv(workingDirectory: Path = Path.of(System.getProperty("user.dir"))): Map { @@ -48,7 +68,10 @@ data class AppConfig( return candidatePaths .filter(Files::isRegularFile) - .fold(emptyMap()) { values, path -> values + parseDotEnv(path) } + .fold(emptyMap()) { values, path -> + logger.info("config_dotenv_loaded path=$path") + values + parseDotEnv(path) + } } private fun parseDotEnv(path: Path): Map { @@ -58,10 +81,8 @@ data class AppConfig( .mapNotNull { line -> val separatorIndex = line.indexOf('=') if (separatorIndex <= 0) return@mapNotNull null - val key = line.substring(0, separatorIndex).trim() if (key.isEmpty()) return@mapNotNull null - val rawValue = line.substring(separatorIndex + 1).trim() key to rawValue.removeWrappingQuotes() } diff --git a/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/Main.kt b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/Main.kt index 92b9d37..c91b5a1 100644 --- a/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/Main.kt +++ b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/Main.kt @@ -1,7 +1,13 @@ package com.vikramaditya.portfolio.visitnotifier +import java.util.logging.Logger + +private val logger = Logger.getLogger("com.vikramaditya.portfolio.visitnotifier.Main") + fun main() { + logger.info("visit_notifier_starting") val config = AppConfig.fromEnvironment() - val server = VisitServer(config, SmtpEmailSender(config)).start() - println("Visit notifier listening on port ${server.address.port}") + val emailSender = ResendEmailSender(config) + val server = VisitServer(config, emailSender).start() + logger.info("visit_notifier_started port=${server.address.port}") } diff --git a/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/ResendEmailSender.kt b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/ResendEmailSender.kt new file mode 100644 index 0000000..4e92fc4 --- /dev/null +++ b/visit-notifier/src/main/kotlin/com/vikramaditya/portfolio/visitnotifier/ResendEmailSender.kt @@ -0,0 +1,107 @@ +package com.vikramaditya.portfolio.visitnotifier + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.add +import java.net.HttpURLConnection +import java.net.URL +import java.util.logging.Logger + +/** + * Sends visit notification emails via the Resend HTTP API (https://resend.com). + * Uses port 443 (HTTPS) — works on Render free tier unlike SMTP port 587 which is blocked. + * + * Required env var: RESEND_API_KEY + * Optional env vars (falls back to AppConfig SMTP fields for from/to): + * VISIT_NOTIFY_FROM — must be a verified sender domain in Resend + * VISIT_NOTIFY_TO — recipient email + */ +class ResendEmailSender( + private val config: AppConfig, +) : EmailSender { + + private val logger = Logger.getLogger(ResendEmailSender::class.java.name) + private val json = Json { encodeDefaults = true } + + companion object { + private const val RESEND_API_URL = "https://api.resend.com/emails" + private const val CONNECT_TIMEOUT_MS = 5_000 + private const val READ_TIMEOUT_MS = 10_000 + } + + override fun sendVisitNotification(visit: AcceptedVisit) { + val apiKey = config.resendApiKey + + val requestBody = buildJsonObject { + put("from", config.visitNotifyFrom) + putJsonArray("to") { add(config.visitNotifyTo) } + put("subject", "Portfolio visit: ${visit.payload.path}") + put("text", buildBody(visit)) + } + + val bodyBytes = json.encodeToString(requestBody).toByteArray(Charsets.UTF_8) + + logger.info("resend_attempt to=${config.visitNotifyTo} path=${visit.payload.path}") + + val url = URL(RESEND_API_URL) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + doOutput = true + setRequestProperty("Authorization", "Bearer $apiKey") + setRequestProperty("Content-Type", "application/json") + setRequestProperty("Accept", "application/json") + } + + try { + connection.outputStream.use { it.write(bodyBytes) } + + val status = connection.responseCode + val responseBody = runCatching { + if (status in 200..299) { + connection.inputStream.bufferedReader().readText() + } else { + connection.errorStream?.bufferedReader()?.readText() ?: "(no error body)" + } + }.getOrElse { "(could not read response body)" } + + if (status in 200..299) { + logger.info("resend_success status=$status path=${visit.payload.path} response=$responseBody") + } else { + logger.severe("resend_failed status=$status path=${visit.payload.path} response=$responseBody") + throw RuntimeException("Resend API returned HTTP $status: $responseBody") + } + } finally { + connection.disconnect() + } + } + + private fun buildBody(visit: AcceptedVisit): String { + val payload = visit.payload + val metadata = visit.requestMetadata + val viewport = "${payload.viewportWidth ?: "unknown"} x ${payload.viewportHeight ?: "unknown"}" + val screen = "${payload.screenWidth ?: "unknown"} x ${payload.screenHeight ?: "unknown"}" + + return """ + A portfolio visit was recorded. + + Timestamp : ${visit.receivedAtIso} + IP : ${metadata.ip} + Path : ${payload.path} + Referrer : ${payload.referrer ?: "(direct)"} + User-Agent : ${payload.userAgent ?: metadata.userAgentHeader ?: "(unknown)"} + Language : ${payload.language ?: "(unknown)"} + Timezone : ${payload.timezone ?: "(unknown)"} + Viewport : $viewport + Screen : $screen + Color mode : ${payload.colorMode ?: "(unknown)"} + Session ID : ${payload.sessionId} + Origin : ${metadata.origin ?: "(unknown)"} + X-Fwd-For : ${metadata.forwardedFor ?: "(none)"} + """.trimIndent() + } +}