From e88991d555c01515d7f4a82fd687329499a11afb Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 17 Oct 2025 06:05:27 -0400 Subject: [PATCH 1/5] Send (wip) validation email with postmark. This is the scaffolding for sending an email validation link with postmark. It can send email but still need to construct the actual link and destination endpoint. --- .envrc.sample | 9 ++++ .github/workflows/test.yml | 1 + .gitignore | 1 + README.md | 5 ++- src/Email.gren | 2 +- src/Main.gren | 73 ++++++++++++++++++++++----------- src/Postmark.gren | 48 ++++++++++++++++++++++ src/Route/Error.gren | 17 ++++---- src/Route/Session.gren | 58 +++++++++++++++++++++++++- src/Session.gren | 6 +-- src/Test/E2E.gren | 21 ++++++++++ src/Test/E2E/Postmark.gren | 24 +++++++++++ src/Test/E2E/Route/Session.gren | 2 +- src/Test/E2E/Session.gren | 2 +- 14 files changed, 229 insertions(+), 40 deletions(-) create mode 100644 .envrc.sample create mode 100644 src/Postmark.gren create mode 100644 src/Test/E2E/Postmark.gren diff --git a/.envrc.sample b/.envrc.sample new file mode 100644 index 0000000..6ba20cc --- /dev/null +++ b/.envrc.sample @@ -0,0 +1,9 @@ +#!/bin/bash + +# Automatically sets up devbox environment when you cd into this directory. +# see https://www.jetify.com/docs/devbox/ide_configuration/direnv/ for details. +eval "$(devbox generate direnv --print-envrc)" + +# Set environment variables for local development. +# Other environments should set these using that environment's configuration. +export POSTMARK_API_TOKEN=abc123 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f967e1..39e007a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: push jobs: test: runs-on: ubuntu-latest + environment: test steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 9cd6767..b48e3d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.envrc .gren db/*.db* dist/app diff --git a/README.md b/README.md index 8113759..5b0bf7c 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ Once task ports land (25S?), switch to sqlite via ports with litestream for back ## Local Development -This project uses [devbox](https://www.jetify.com/devbox). +This project uses [devbox](https://www.jetify.com/devbox) and [direnv](https://direnv.net/). +Install both for the smoothest experience. + +Copy `.envrc.sample` to `.envrc` and set environment variables appropriately. You can run the server with `devbox services up` diff --git a/src/Email.gren b/src/Email.gren index c3097d9..a100e3e 100644 --- a/src/Email.gren +++ b/src/Email.gren @@ -38,4 +38,4 @@ toString (Email emailString) = -} example : Email example = - Email "a@example.com" + Email "test@blackhole.postmarkapp.com" diff --git a/src/Main.gren b/src/Main.gren index b641db5..5660b4b 100644 --- a/src/Main.gren +++ b/src/Main.gren @@ -3,6 +3,7 @@ module Main exposing (main) import Bytes exposing (Bytes) import Crypto import Db +import Dict import Email exposing (Email) import Db.Encode import HttpClient @@ -11,6 +12,7 @@ import HttpServer.Response as Response exposing (Response) import Init import Json.Decode import Node exposing (Environment, Program) +import Postmark import Registry.Db import Route.Error import Route.Session @@ -28,8 +30,8 @@ main = } -config : { host : String, port_ : Int } -config = +serverConfig : { host : String, port_ : Int } +serverConfig = { host = "0.0.0.0" , port_ = 3000 } @@ -43,6 +45,7 @@ type alias Model = , stderr : Stream.Writable Bytes , server : Maybe HttpServer.Server , db : Db.Connection + , postmark : Maybe Postmark.Configuration , secureContext : Maybe Crypto.SecureContext } @@ -51,9 +54,20 @@ init : Environment -> Init.Task { model : Model, command : Cmd Msg } init env = Init.await HttpServer.initialize <| \serverPermission -> Init.await HttpClient.initialize <| \httpPerm -> + Init.awaitTask Node.getEnvironmentVariables <| \envVars -> let db = Helper.initDb httpPerm + + postmark = + envVars + |> Dict.get "POSTMARK_API_TOKEN" + |> Maybe.map + (\token -> + { httpPermission = httpPerm + , apiToken = token + } + ) in Node.startProgram { model = @@ -62,12 +76,13 @@ init env = , server = Nothing , db = db , secureContext = Nothing + , postmark = postmark } , command = Cmd.batch [ Registry.Db.migrate db |> Task.attempt DbMigrationResult - , HttpServer.createServer serverPermission config + , HttpServer.createServer serverPermission serverConfig |> Task.attempt CreateServerResult , Crypto.getSecureContext |> Task.attempt SecureContextResult @@ -94,7 +109,7 @@ update msg model = Ok server -> { model = { model | server = Just server } , command = - "Server started: http://" ++ config.host ++ ":" ++ String.fromInt config.port_ + "Server started: http://" ++ serverConfig.host ++ ":" ++ String.fromInt serverConfig.port_ |> print model.stdout |> Task.execute } @@ -161,27 +176,39 @@ route model request response = request.url.path |> String.split "/" |> Array.keepIf (\s -> s /= "") + + config = + { secureContext = model.secureContext + , postmark = model.postmark + } in - when { method = request.method, path = path, secureContext = model.secureContext } is + when config is { secureContext = Nothing } -> - Route.Error.noSecureContext response - - { method = POST, path = [ "session" ], secureContext = Just secureContext } -> - when getEmail request.body is - Just email -> - Route.Session.create - { db = model.db - , secureContext = secureContext - , requestData = { email = email } - , response = response - } - - Nothing -> - Route.Error.invalidRequestData response - "Request json did not contain a valid `email` field." - - _ -> - Route.Error.notFound response + Route.Error.serverError response "Missing secure context." + + { postmark = Nothing } -> + Route.Error.serverError response "Missing postmark config." + + { postmark = Just postmark, secureContext = Just secureContext } -> + when { method = request.method, path = path } is + + { method = POST, path = [ "session" ] } -> + when getEmail request.body is + Just email -> + Route.Session.create + { db = model.db + , postmark = postmark + , secureContext = secureContext + , requestData = { email = email } + , response = response + } + + Nothing -> + Route.Error.invalidRequestData response + "Request json did not contain a valid `email` field." + + _ -> + Route.Error.notFound response getEmail : Bytes -> Maybe Email diff --git a/src/Postmark.gren b/src/Postmark.gren new file mode 100644 index 0000000..f9249e2 --- /dev/null +++ b/src/Postmark.gren @@ -0,0 +1,48 @@ +module Postmark exposing (Configuration, send) + + +import HttpClient as Http +import Json.Encode +import Task exposing (Task) + + +type alias Configuration = + { apiToken : String + , httpPermission : Http.Permission + } + + +url : String +url = + "https://api.postmarkapp.com/email" + + +-- Might not want to always hardcode this. But for now we're only using this for +-- email validation. +from : String +from = + "no-reply@gren-lang.org" + + +send : + Configuration + -> { to : String, subject : String, textBody : String } + -> Task Http.Error {} +send config { to, subject, textBody } = + let + body = + Json.Encode.object + [ { key = "From", value = Json.Encode.string from } + , { key = "To", value = Json.Encode.string to } + , { key = "Subject", value = Json.Encode.string subject } + , { key = "TextBody", value = Json.Encode.string textBody } + , { key = "MessageStream", value = Json.Encode.string "outbound" } + ] + in + Http.post url + |> Http.withHeader "Accept" "application/json" + |> Http.withHeader "Content-Type" "application/json" + |> Http.withHeader "X-Postmark-Server-Token" config.apiToken + |> Http.withJsonBody body + |> Http.send config.httpPermission + |> Task.map (\_ -> {}) diff --git a/src/Route/Error.gren b/src/Route/Error.gren index 7a2128c..64d3e5c 100644 --- a/src/Route/Error.gren +++ b/src/Route/Error.gren @@ -1,7 +1,7 @@ module Route.Error exposing ( notFound - , noSecureContext , invalidRequestData + , serverError ) @@ -17,17 +17,18 @@ notFound response = |> Task.succeed -noSecureContext : Response -> Task Never Response -noSecureContext response = +invalidRequestData : Response -> String -> Task Never Response +invalidRequestData response message = response - |> Response.setStatus 500 - |> Response.setBody "Missing secure context." + |> Response.setStatus 400 + |> Response.setBody message |> Task.succeed -invalidRequestData : Response -> String -> Task Never Response -invalidRequestData response message = +serverError : Response -> String -> Task Never Response +serverError response message = response - |> Response.setStatus 400 + |> Response.setStatus 500 |> Response.setBody message |> Task.succeed + diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 73b94bd..6e6ab3d 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -7,8 +7,10 @@ import Bytes exposing (Bytes) import Crypto import Db import Email exposing (Email) +import HttpClient import HttpServer exposing (Request) import HttpServer.Response as Response exposing (Response) +import Postmark import Json.Decode import Json.Encode import Session exposing (Session) @@ -18,22 +20,31 @@ import User exposing (User) type Error = DbError Db.Error + | SendEmailFailed HttpClient.Error + + +-- ENDPOINTS create : { db : Db.Connection + , postmark : Postmark.Configuration , secureContext : Crypto.SecureContext , requestData : { email : Email } , response : Response } -> Task Never Response -create { db, secureContext, requestData, response } = +create { db, secureContext, postmark, requestData, response } = findOrCreateUser db requestData.email |> Task.andThen (createSession db secureContext ) + |> Task.andThen (sendEmailValidationLink postmark) |> Task.map (createSuccess response) |> Task.onError (createFailed response) +-- ACTIONS + + findOrCreateUser : Db.Connection -> Email -> Task Error User findOrCreateUser db email = User.findOrCreate db email @@ -46,6 +57,21 @@ createSession db secureContext user = |> Task.mapError DbError +sendEmailValidationLink : Postmark.Configuration -> Session -> Task Error Session +sendEmailValidationLink postmarkConfig session = + { to = session.user.email |> Email.toString + , subject = "Gren: Confirm your email address" + , textBody = + "TODO: link with this token: " ++ session.emailValidationToken + } + |> Postmark.send postmarkConfig + |> Task.mapError SendEmailFailed + |> Task.map (\_ -> session) + + +-- RESPONSES + + createSuccess : Response -> Session -> Response createSuccess response session = let @@ -65,7 +91,35 @@ createFailed : Response -> Error -> Task x Response createFailed response error = when error is DbError e -> + -- TODO: log the actual error response |> Response.setStatus 500 - -- TODO: helpful error message in json body + |> setErrorMessage "Database error" |> Task.succeed + + + SendEmailFailed e -> + let + message = + "Failed to send email: " ++ + HttpClient.errorToString e + in + response + |> Response.setStatus 500 + |> setErrorMessage message + |> Task.succeed + + +-- HELPERS + + +setErrorMessage : String -> Response -> Response +setErrorMessage message response = + let + body = + Json.Encode.object + [ { key = "message", value = Json.Encode.string message } ] + in + response + |> Response.setHeader "Content-Type" "application/json" + |> Response.setBody (Json.Encode.encode 0 body) diff --git a/src/Session.gren b/src/Session.gren index cf1d6fb..fb49c7c 100644 --- a/src/Session.gren +++ b/src/Session.gren @@ -15,7 +15,7 @@ import User exposing (User) type alias Session = { created : Time.Posix - , userId : Int + , user : User , emailValidationToken : String , fetchSessionToken : String } @@ -33,7 +33,7 @@ create { db, user, secureContext } = Task.await Time.now <| \now -> dbInsert db { created = now - , userId = user.id + , user = user , emailValidationToken = uuid1 , fetchSessionToken = uuid2 } @@ -50,7 +50,7 @@ dbInsert db session = """ , parameters = [ Db.Encode.posix "created" session.created - , Db.Encode.int "user_id" session.userId + , Db.Encode.int "user_id" session.user.id , Db.Encode.string "email_validation_token" session.emailValidationToken , Db.Encode.string "fetch_session_token" session.fetchSessionToken ] diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index 48af396..dab63d7 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -1,11 +1,15 @@ module Test.E2E exposing (tests) import Crypto +import Dict import Expect import HttpClient +import Node +import Task import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test) import Test.E2E.Helper exposing (get, expectBadStatus, initDb) import Test.E2E.Route.Session +import Test.E2E.Postmark import Test.E2E.Session import Test.E2E.User @@ -15,12 +19,29 @@ tests httpPerm = let db = initDb httpPerm + + getPostmarkConfig = + Node.getEnvironmentVariables + |> Task.map (Dict.get "POSTMARK_API_TOKEN") + |> Task.andThen + (\maybeToken -> + when maybeToken is + Nothing -> + Task.fail "missing postmark api token" + Just token -> + Task.succeed + { httpPermission = httpPerm + , apiToken = token + } + ) in [ await "Get secure context" Crypto.getSecureContext <| \secureContext -> + await "Get postmark config" getPostmarkConfig <| \postmark -> concat [ describe "Session route tests" (Test.E2E.Route.Session.tests httpPerm) , describe "Session module tests" (Test.E2E.Session.tests db secureContext) , describe "User module tests" (Test.E2E.User.tests db) + , describe "Postmark module tests" (Test.E2E.Postmark.tests postmark) , describe "Home route tests" -- There is no Home route yet [ awaitError "GET /" (get httpPerm "/") <| \response -> diff --git a/src/Test/E2E/Postmark.gren b/src/Test/E2E/Postmark.gren new file mode 100644 index 0000000..318cd26 --- /dev/null +++ b/src/Test/E2E/Postmark.gren @@ -0,0 +1,24 @@ +module Test.E2E.Postmark exposing (tests) + + +import Email +import Expect +import HttpClient +import Postmark +import Test.Runner.Effectful exposing (Test, await, awaitError, describe, test) + + +tests : Postmark.Configuration -> Array Test +tests postmark = + [ await "Postmark.send" + (Postmark.send postmark + { to = Email.example |> Email.toString + , subject = "postmark test" + , textBody = "postmark test body" + } + ) + (\result -> + test "Succeeds" <| \_ -> + Expect.equal {} result + ) + ] diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index 9a002f4..bb3cfa9 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -38,7 +38,7 @@ tests httpPerm = , describe "Create session" <| let goodEmail = - "abc@example.com" + Email.example |> Email.toString withNoEmail = post httpPerm "/session" diff --git a/src/Test/E2E/Session.gren b/src/Test/E2E/Session.gren index 32da64e..d1027ce 100644 --- a/src/Test/E2E/Session.gren +++ b/src/Test/E2E/Session.gren @@ -37,7 +37,7 @@ tests db secureContext = [ test "Session is created" <| \_ -> Expect.equal (countBeforeCreate + 1) countAfterCreate , test "Session is created for user" <| \_ -> - Expect.equal user.id session.userId + Expect.equal user session.user ] ] ] From 37c2d88e69a8817f23aa8b5feb1734bfcac07b71 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Wed, 5 Nov 2025 05:19:58 -0500 Subject: [PATCH 2/5] don't expose internal error message to api --- src/Route/Session.gren | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 6e6ab3d..3c41c36 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -99,14 +99,10 @@ createFailed response error = SendEmailFailed e -> - let - message = - "Failed to send email: " ++ - HttpClient.errorToString e - in + -- TODO: log the actual error response |> Response.setStatus 500 - |> setErrorMessage message + |> setErrorMessage "Failed to send email" |> Task.succeed From e75ec56eed419da6eec820e8ccf4dc6c19249db2 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Thu, 6 Nov 2025 05:57:42 -0500 Subject: [PATCH 3/5] rename POSTMARK_API_TOKEN to POSTMARK_SERVER_TOKEN api and server tokens are different things and we want a server token for email validation links --- .envrc.sample | 2 +- src/Main.gren | 2 +- src/Test/E2E.gren | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.envrc.sample b/.envrc.sample index 6ba20cc..fdeec69 100644 --- a/.envrc.sample +++ b/.envrc.sample @@ -6,4 +6,4 @@ eval "$(devbox generate direnv --print-envrc)" # Set environment variables for local development. # Other environments should set these using that environment's configuration. -export POSTMARK_API_TOKEN=abc123 +export POSTMARK_SERVER_TOKEN=abc123 diff --git a/src/Main.gren b/src/Main.gren index 5660b4b..1a0bfa6 100644 --- a/src/Main.gren +++ b/src/Main.gren @@ -61,7 +61,7 @@ init env = postmark = envVars - |> Dict.get "POSTMARK_API_TOKEN" + |> Dict.get "POSTMARK_SERVER_TOKEN" |> Maybe.map (\token -> { httpPermission = httpPerm diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index dab63d7..2d8e90d 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -22,7 +22,7 @@ tests httpPerm = getPostmarkConfig = Node.getEnvironmentVariables - |> Task.map (Dict.get "POSTMARK_API_TOKEN") + |> Task.map (Dict.get "POSTMARK_SERVER_TOKEN") |> Task.andThen (\maybeToken -> when maybeToken is From 3e27b6efe8b7d2ff89fcf3ffe4de4358b3b4f3a6 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Thu, 6 Nov 2025 06:03:47 -0500 Subject: [PATCH 4/5] Use postmark's api test token for tests. I also set this as the POSTMARK_SERVER_TOKEN in the test environment for github actions. --- src/Test/E2E.gren | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index 2d8e90d..ba91c96 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -20,23 +20,13 @@ tests httpPerm = db = initDb httpPerm - getPostmarkConfig = - Node.getEnvironmentVariables - |> Task.map (Dict.get "POSTMARK_SERVER_TOKEN") - |> Task.andThen - (\maybeToken -> - when maybeToken is - Nothing -> - Task.fail "missing postmark api token" - Just token -> - Task.succeed - { httpPermission = httpPerm - , apiToken = token - } - ) + postmark = + -- https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark + { apiToken = "POSTMARK_API_TEST" + , httpPermission = httpPerm + } in [ await "Get secure context" Crypto.getSecureContext <| \secureContext -> - await "Get postmark config" getPostmarkConfig <| \postmark -> concat [ describe "Session route tests" (Test.E2E.Route.Session.tests httpPerm) , describe "Session module tests" (Test.E2E.Session.tests db secureContext) From ac99d23f92658de93e44ae75c2e3a433bdbb9e78 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Thu, 6 Nov 2025 06:09:00 -0500 Subject: [PATCH 5/5] Suggest POSTMARK_API_TEST is sample config --- .envrc.sample | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.envrc.sample b/.envrc.sample index fdeec69..8a918ec 100644 --- a/.envrc.sample +++ b/.envrc.sample @@ -4,6 +4,9 @@ # see https://www.jetify.com/docs/devbox/ide_configuration/direnv/ for details. eval "$(devbox generate direnv --print-envrc)" -# Set environment variables for local development. +# Set environment variables for local development below. # Other environments should set these using that environment's configuration. -export POSTMARK_SERVER_TOKEN=abc123 + +# Use POSTMARK_API_TEST if you don't need to send real emails. +# https://postmarkapp.com/support/article/1213-best-practices-for-testing-your-emails-through-postmark +export POSTMARK_SERVER_TOKEN=POSTMARK_API_TEST