Compare commits

..

5 Commits

Author SHA1 Message Date
94d9993c22 Update ini -> toml in readme 2024-05-10 21:44:41 +00:00
6c42b31ae9 Add Markdown parsing to the preview 2024-05-06 19:35:53 +01:00
cb425edd8b Migrate to the new toml file for config from the go email sender 2024-04-23 23:38:44 +01:00
ed872ddfa3 Add buttons to actually send emails either the test address or mailing list 2024-04-23 20:48:46 +01:00
Jack Punter
45d25d8990 Subject rendering
This isn't the best solution and might not even be worth doing as the subject template doesn't do anything and means i have to call the API in multiple paces
2024-04-22 00:21:36 +01:00
8 changed files with 307 additions and 114 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
tmp/
app.ini
app.toml

View File

@@ -2,7 +2,7 @@
Simple Go webserver to help HMC Meetups hosts see their mailing lists and prepare emails to send
# How to use
Copy your app.ini from HMCsend-email copy into the root of this repo, run the server and
Copy your app.toml from HMCsend-email copy into the root of this repo, run the server and
navigate to localhost in the browser. It should load your current mailing list at the bottom and show a
text box in which you can write your mock email into. THIS IS VERY WIP, it currently just echos this onto
the right hand section, but eventually we should render the email and put that on the right panel instead.

4
go.mod
View File

@@ -2,4 +2,6 @@ module send_email_site
go 1.21.6
require gopkg.in/ini.v1 v1.67.0 // indirect
require github.com/pelletier/go-toml/v2 v2.2.1
require github.com/russross/blackfriday/v2 v2.1.0

24
go.sum
View File

@@ -1,2 +1,22 @@
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

277
main.go
View File

@@ -15,134 +15,114 @@ import (
"strings"
"time"
"gopkg.in/ini.v1"
"github.com/pelletier/go-toml/v2"
"github.com/russross/blackfriday/v2"
)
// ------------- Api configs -------------
type DefaultSectionConfig struct {
Live bool
TestEmail string
SenderName string
}
type HMCConfig struct {
ApiSecret string
ApiUrl string
City string
}
type PostmarkConfig struct {
ServerToken string
ApiUrl string
TemplateId int
SenderEmail string
MessageStream string
}
type ApiConfig struct {
Default DefaultSectionConfig
Hmc HMCConfig
Postmark PostmarkConfig
}
// ------------- HMC Api Responses -------------
type EmailsResonse struct {
Emails []string `json:"emails"`
BatchSize int `toml:"batch_size"`
TestEmail string `toml:"test_email"`
HMC struct {
ApiUrl string `toml:"api_url"`
SharedSecret string `toml:"shared_secret"`
City string `toml:"city"`
} `toml:"hmc"`
Postmark struct {
ServerToken string `toml:"server_token"`
ApiUrl string `toml:"api_url"`
TemplateId int `toml:"template_id"`
SenderName string `toml:"sender_name"`
SenderEmail string `toml:"sender_email"`
MessageStream string `toml:"message_stream"`
} `toml:"postmark"`
}
// Load the HMCsend-email app.ini and store the values in a structure, some of the keys in here
// we probably don't actualy care about for this usage.
func loadConfig() ApiConfig {
// Read the config file and get the HMC api settings
config, err := ini.Load("app.ini")
configFile := "app.toml"
data, err := os.ReadFile(configFile)
if err != nil {
panic(err)
fmt.Println("Error reading TOML file:", err)
os.Exit(1)
}
defSection := config.Section("DEFAULT")
def := DefaultSectionConfig{
Live: defSection.Key("LIVE").MustBool(),
TestEmail: defSection.Key("TEST_EMAIL").String(),
SenderName: defSection.Key("SENDER_NAME").String(),
config := ApiConfig{}
if err := toml.Unmarshal(data, &config); err != nil {
fmt.Println("Error unmarshaling TOML:", err)
os.Exit(1)
}
return config
}
hmcSection := config.Section("hmc")
hmc := HMCConfig{
ApiSecret: hmcSection.Key("SHARED_SECRET").String(),
ApiUrl: hmcSection.Key("API_URL").String(),
City: hmcSection.Key("CITY").String(),
func ParseArguments(rawArgs string) map[string]string {
pairs := strings.Split(rawArgs, "&")
result := make(map[string]string)
for _, pair := range pairs {
key_val := strings.Split(pair, "=")
if len(key_val) != 2 {
log.Fatalf("Cannot parse argument %v\n", pair)
}
result[key_val[0]] = key_val[1]
}
return result
}
postmarkSection := config.Section("postmark")
postmark := PostmarkConfig{
ServerToken: postmarkSection.Key("SERVER_TOKEN").String(),
ApiUrl: postmarkSection.Key("API_URL").String(),
TemplateId: postmarkSection.Key("TEMPLATE_ID").MustInt(),
SenderEmail: postmarkSection.Key("SENDER_EMAIL").String(),
MessageStream: postmarkSection.Key("MESSAGE_STREAM").String(),
/*
* This is taken directly from the https://git.handmadecities.com/meetups/meetupinvite2000 repo
* Although I have added the blackfriday processing to it rather than doing it separately.
*/
func parseMailFile(contents string) (subject string, body string, valid bool) {
/* Remove leading and trailing newlines */
/* *** */
contents = strings.TrimSpace(contents)
lines := strings.Split(contents, "\n")
/* Check if there is at least one line */
/* *** */
if len(lines) == 0 {
return "", "", false
}
return ApiConfig{
Default: def,
Hmc: hmc,
Postmark: postmark,
}
/* Check if the first line is a title with the '#' Markdown symbol */
/* *** */
if strings.HasPrefix(lines[0], "#") { // Extract subject without the '#' symbol and leading/trailing spaces
subject = strings.TrimSpace(strings.TrimPrefix(lines[0], "#"))
} else {
// If the first line is not a title, return an error
return "", "", false
}
func main() {
apiConfig := loadConfig()
postmarkTemplate := getPostmarkTemplate(apiConfig.Postmark)
/* Concatenate the remaining lines to form the body */
/* *** */
body = strings.Join(lines[1:], "\n")
// Prepare the go html templates and static file server
templates := template.Must(template.ParseGlob("templates/*.html"))
static_files := http.FileServer(http.Dir("static/"))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", static_files))
// The base endpoint for the web-app simply renders the index tempalte
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Hello '%v %v' handler", r.Method, r.URL)
templates.ExecuteTemplate(w, "index", nil)
})
// Endpoint called when editing the text area and posts to the postmark api to generate the email preview
mux.HandleFunc("/mail-content", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Hello '%v %v' handler", r.Method, r.URL)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
/* Check if the body is not empty */
/* *** */
if body == "" {
return "", "", false
}
defer r.Body.Close()
textarea_content := string(body)
decoded_textarea, err := url.QueryUnescape(textarea_content)
if err != nil {
panic(err)
html := blackfriday.Run([]byte(body))
/* If all checks pass, set valid to true */
/* *** */
return subject, string(html), true
}
email_content, _ := strings.CutPrefix(decoded_textarea, "email_input=")
// Return a response to HTMX
model := PostmarkTemplateModel{
Name: apiConfig.Default.SenderName,
Email: apiConfig.Postmark.SenderEmail,
Body: email_content,
Subject: "This is a test subject", // TODO(jack): Get from user input on page
func GetMailingList(cfg ApiConfig) []string {
type EmailsResonse struct {
Emails []string `json:"emails"`
}
w.Write([]byte(renderPostmarkTemplate(apiConfig.Postmark, postmarkTemplate, model)))
})
// Endpoint called on load or button press by htmx to retrieve and populate the mailing list
mux.HandleFunc("/mailing_list", func(w http.ResponseWriter, r *http.Request) {
body := []byte(fmt.Sprintf("{\"city\": \"%s\"}", apiConfig.Hmc.City))
req, err := http.NewRequest("POST", apiConfig.Hmc.ApiUrl, bytes.NewBuffer(body))
body := []byte(fmt.Sprintf("{\"city\": \"%s\"}", cfg.HMC.City))
req, err := http.NewRequest("POST", cfg.HMC.ApiUrl, bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Authorization", apiConfig.Hmc.ApiSecret)
req.Header.Add("Authorization", cfg.HMC.SharedSecret)
client := &http.Client{}
res, err := client.Do(req)
@@ -156,8 +136,107 @@ func main() {
if decode_error != nil {
panic(decode_error)
}
return response.Emails
}
templates.ExecuteTemplate(w, "mailing_list", response)
func main() {
apiConfig := loadConfig()
postmarkTemplate := getPostmarkTemplate(apiConfig)
// Prepare the go html templates and static file server
templates := template.Must(template.ParseGlob("templates/*.html"))
static_files := http.FileServer(http.Dir("static/"))
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", static_files))
// The base endpoint for the web-app simply renders the index tempalte
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
templates.ExecuteTemplate(w, "index", nil)
})
// Endpoint called when editing the text area and posts to the postmark api to generate the email preview
mux.HandleFunc("/mail-content", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer r.Body.Close()
arguments := ParseArguments(string(body))
decoded_markdown, err := url.QueryUnescape(arguments["email_input"])
if err != nil {
panic(err)
}
subject, html_body, valid := parseMailFile(decoded_markdown)
if !valid {
log.Print("Failed to parse the provided markdown")
return
}
// Return a response to HTMX
model := PostmarkTemplateModel{
Name: apiConfig.Postmark.SenderName,
Email: apiConfig.Postmark.SenderEmail,
Body: html_body,
Subject: subject, // TODO(jack): Get from user input on page
}
renderedEmail := renderPostmarkTemplate(apiConfig, postmarkTemplate, model)
// Render the priview to a file so that we can include it as an Iframe as the rendered HTML is a full document.
err = os.WriteFile("./static/preview.html", []byte(renderedEmail.Html), 0666)
if err != nil {
log.Panicf("Failed to write file %v", err)
}
templates.ExecuteTemplate(w, "mail_content", renderedEmail.Subject)
})
// Endpoint called on load or button press by htmx to retrieve and populate the mailing list
mux.HandleFunc("/mailing_list", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
mailing_list := GetMailingList(apiConfig)
templates.ExecuteTemplate(w, "mailing_list", mailing_list)
})
mux.HandleFunc("/send_email", func(w http.ResponseWriter, r *http.Request) {
log.Printf("%v %v", r.Method, r.URL)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer r.Body.Close()
body_string := string(body)
arguments := ParseArguments(body_string)
fmt.Printf("%v\n", arguments)
var recipients []string
if arguments["destination"] == "test" {
recipients = []string{apiConfig.TestEmail}
} else if arguments["destination"] == "mailing_list" {
recipients = GetMailingList(apiConfig)
} else {
panic("unknown destination in arguments")
}
decoded_markdown, err := url.QueryUnescape(arguments["email_input"])
if err != nil {
panic(err)
}
subject, html_body, valid := parseMailFile(decoded_markdown)
if !valid {
log.Print("Failed to parse the provided markdown")
return
}
sendBatchWithTemplate(apiConfig, html_body, subject, recipients)
})
srv := &http.Server{

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
@@ -64,16 +65,21 @@ type ValidationResponse struct {
SuggestedTemplateModel interface{}
}
type Email struct {
Html string
Subject string
}
// Gets the tempalte in the postmark config section of the app.ini file from teh postmark server so we can render it
func getPostmarkTemplate(cfg PostmarkConfig) PostmarkTemplate {
func getPostmarkTemplate(cfg ApiConfig) PostmarkTemplate {
// Get Template
getTemplateURL := fmt.Sprintf("https://api.postmarkapp.com/templates/%v", cfg.TemplateId)
getTemplateURL := fmt.Sprintf("https://api.postmarkapp.com/templates/%v", cfg.Postmark.TemplateId)
getReq, err := http.NewRequest(http.MethodGet, getTemplateURL, bytes.NewBuffer([]byte("")))
if err != nil {
panic(err)
}
getReq.Header.Add("Accept", "application/json")
getReq.Header.Add("X-Postmark-Server-Token", cfg.ServerToken)
getReq.Header.Add("X-Postmark-Server-Token", cfg.Postmark.ServerToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
@@ -92,7 +98,7 @@ func getPostmarkTemplate(cfg PostmarkConfig) PostmarkTemplate {
}
// Given a tempalte and model use the postmark tempalte validation api to render the template
func renderPostmarkTemplate(cfg PostmarkConfig, template PostmarkTemplate, model PostmarkTemplateModel) string {
func renderPostmarkTemplate(cfg ApiConfig, template PostmarkTemplate, model PostmarkTemplateModel) Email {
bodyObj := ValidateTemplateBody{
Subject: template.Subject,
HTMLBody: template.HtmlBody,
@@ -112,7 +118,7 @@ func renderPostmarkTemplate(cfg PostmarkConfig, template PostmarkTemplate, model
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("X-Postmark-Server-Token", cfg.ServerToken)
req.Header.Add("X-Postmark-Server-Token", cfg.Postmark.ServerToken)
client := &http.Client{}
resp, err := client.Do(req)
@@ -131,5 +137,67 @@ func renderPostmarkTemplate(cfg PostmarkConfig, template PostmarkTemplate, model
panic(decode_error)
}
return response.HtmlBody.RenderedContent
return Email{
Html: response.HtmlBody.RenderedContent,
Subject: response.Subject.RenderedContent,
}
}
type PostmarkBatchArguments struct {
From string
To string
TemplateId int
TemplateModel PostmarkTemplateModel
MessageStream string
}
func sendBatchWithTemplate(cfg ApiConfig, email, subject string, recipients []string) {
type BatchAPIArgs struct {
Messages []PostmarkBatchArguments
}
bodyObj := &BatchAPIArgs{}
for _, recipient := range recipients {
args := PostmarkBatchArguments{
From: cfg.Postmark.SenderEmail,
To: recipient,
TemplateId: cfg.Postmark.TemplateId,
TemplateModel: PostmarkTemplateModel{
Name: cfg.Postmark.SenderName,
Email: cfg.Postmark.SenderEmail,
Body: email,
Subject: subject,
},
MessageStream: cfg.Postmark.MessageStream,
}
bodyObj.Messages = append(bodyObj.Messages, args)
}
body, err := json.Marshal(bodyObj)
if err != nil {
panic(err)
}
req, err := http.NewRequest("POST", "https://api.postmarkapp.com/email/batchWithTemplates", bytes.NewBuffer(body))
if err != nil {
panic(err)
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-Postmark-Server-Token", cfg.Postmark.ServerToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
rawBodyContent, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
log.Printf("batchWithTemplates responded with: %s\n", string(rawBodyContent))
}

View File

@@ -11,29 +11,27 @@
}
}
* {
box-sizing: border-box;
}
body {
overflow-y: scroll;
overflow-x: hidden;
width: 100%;
height: 100%;
background-color: #111;
color: #CCC;
tab-size: 4;
line-height: 1.2;
/* text-align: justify; */
}
body {
overflow-y: scroll;
/* Show vertical scrollbar */
}
.main-container {
margin-left: auto;
margin-right: auto;
width: 95%;
max-width: 1080px;
max-width: 1280px;
padding: 1em 0 1em 0;
/* padding-top:1em;
padding-bottom:1em; */
}
.content {
@@ -87,9 +85,15 @@ ul {
.row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
.column {
padding: 0.5em;
border-color: #E8B;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
width: 95%;
}

View File

@@ -19,19 +19,28 @@
<div class="content">
<h1> This is an input </h1>
<div class="row">
<div class="column">
<div style="display: flex; justify-content: space-between;">
<span class="input_label">Subject:</span>
<input name="subject" type="text" id="subject">
</div><br>
<textarea name="email_input" id="typebox" rows="32" cols="64" style="resize: none;"
<div class="column" stye="align-items: center">
<textarea name="email_input" id="email_input"
style="resize: none; width: 100%; height: 639px; padding: 6px 10px; border-radius: 4px; font-family: inter; font-size: 1em;"
hx-trigger="keyup changed delay:1000ms" hx-post="/mail-content" hx-target="#email-preview"
placeholder="Type out your email here..."></textarea>
<br>
<div class="row" style="justify-content: space-around;">
<button hx-post="/send_email" hx-include="[id='email_input']" hx-vals='{"destination": "test"}'
hx-swap="none">
Send Test Email
</button>
<button hx-post="/send_email" hx-include="[id='email_input']"
hx-vals='{"destination": "mailing_list"}'
hx-confirm="You are about to send an email to your entire mailing list. Are you sure?"
hx-swap="none">
Send Email
</button>
</div>
</div>
<div class="column">
<div id="subject-preview">Subject preview here</div><br>
<div id="email-preview"></div>
<div id="email-preview" style="display: flex; flex-direction: column; align-items: center;"></div>
</div>
</div>
@@ -53,8 +62,19 @@
{{ block "mailing_list" . }}
<ul style="columns: 2;">
{{ range .Emails }}
{{ range . }}
<li><a href="mailto:{{ . }}"> {{ . }} </a></li>
{{end}}
</ul>
{{end}}
{{ block "mail_content" . }}
<div style="display: flex; flex-direction: row; width: 100%; padding-left: 4px;">
<div>Subject: </div>
<div style="padding-left: 5%;">{{ . }}</div>
</div>
<br>
<iframe src="/static/preview.html" style="width: 600px; height: 600px; border-radius: 4px;"
title="Email Preivew"></iframe>
{{ end }}