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
18 changes: 13 additions & 5 deletions visit-notifier/.env.example
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
PORT=8787
ALLOWED_ORIGINS=https://<your-gh-pages-domain>,https://<your-custom-domain>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
// 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,
Expand All @@ -18,26 +22,42 @@ data class AppConfig(
val maxPayloadBytes: Int,
) {
companion object {
private val logger = Logger.getLogger(AppConfig::class.java.name)

fun fromEnvironment(env: Map<String, String> = 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,
rateLimitMaxRequests = mergedEnv["VISIT_RATE_LIMIT_MAX_REQUESTS"]?.toIntOrNull() ?: 30,
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<String, String> {
Expand All @@ -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<String, String> {
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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}")
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading