package main import ( "bytes" "context" "encoding/json" "fmt" "html/template" "io" "log" "net/http" "net/url" "os" "os/signal" "strings" "time" "github.com/pelletier/go-toml/v2" "github.com/russross/blackfriday/v2" ) // ------------- Api configs ------------- type ApiConfig struct { 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 { configFile := "app.toml" data, err := os.ReadFile(configFile) if err != nil { fmt.Println("Error reading TOML file:", err) os.Exit(1) } config := ApiConfig{} if err := toml.Unmarshal(data, &config); err != nil { fmt.Println("Error unmarshaling TOML:", err) os.Exit(1) } return config } 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 } /* * 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 } /* 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 } /* Concatenate the remaining lines to form the body */ /* *** */ body = strings.Join(lines[1:], "\n") /* Check if the body is not empty */ /* *** */ if body == "" { return "", "", false } html := blackfriday.Run([]byte(body)) /* If all checks pass, set valid to true */ /* *** */ return subject, string(html), true } func GetMailingList(cfg ApiConfig) []string { type EmailsResonse struct { Emails []string `json:"emails"` } 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", cfg.HMC.SharedSecret) client := &http.Client{} res, err := client.Do(req) if err != nil { panic(err) } defer res.Body.Close() response := &EmailsResonse{} decode_error := json.NewDecoder(res.Body).Decode(&response) if decode_error != nil { panic(decode_error) } return response.Emails } 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{ Addr: "0.0.0.0:80", // Good practice to set timeouts to avoid Slowloris attacks. WriteTimeout: time.Second * 15, ReadTimeout: time.Second * 15, IdleTimeout: time.Second * 60, Handler: mux, } go func() { if err := srv.ListenAndServe(); err != nil { log.Println(err) } }() ////////////////////////////// Cleanup ////////////////////////////// c := make(chan os.Signal, 1) // We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C) // SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught. signal.Notify(c, os.Interrupt) log.Println("Waiting for interrupt") // Block until we receive our signal. <-c // Create a deadline to wait for. ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // Defer calling the canclefunction returned from context.WithTimeout defer cancel() // Doesn't block if no connections, but will otherwise wait // until the timeout deadline. srv.Shutdown(ctx) // Optionally, you could run srv.Shutdown in a goroutine and block on // <-ctx.Done() if your application should wait for other services // to finalize based on context cancellation. log.Println("shutting down") os.Exit(0) }