Add support for sending SMS

This commit is contained in:
Eli Ribble 2026-01-17 01:13:27 +00:00
parent 8ab0b78e6e
commit 7abaebe496
No known key found for this signature in database
5 changed files with 249 additions and 75 deletions

58
comms/email.go Normal file
View file

@ -0,0 +1,58 @@
package comms
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/rs/zerolog/log"
)
type AttachmentRequest struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
type EmailRequest struct {
From string `json:"from"`
To string `json:"to"`
CC []string `json:"cc,omitempty"`
BCC []string `json:"bcc,omitempty"`
Subject string `json:"subject"`
Text string `json:"text"`
HTML string `json:"html,omitempty"`
Attachments []AttachmentRequest `json:"attachments,omitempty"`
Sender string `json:"sender"`
ReplyTo string `json:"replyTo,omitempty"`
InReplyTo string `json:"inReplyTo,omitempty"`
References []string `json:"references,omitempty"`
}
type EmailResponse struct {
Message string `json:"message"`
}
func SendEmail(email EmailRequest) error {
url := "https://api.forwardemail.net/v1/emails"
payload, err := json.Marshal(email)
if err != nil {
return fmt.Errorf("Failed to marshal email request: %w", err)
}
//payload := strings.NewReader("{\n \"from\": \"\",\n \"to\": \"\",\n \"cc\": \"\",\n \"bcc\": \"\",\n \"subject\": \"\",\n \"text\": \"\",\n \"html\": \"\",\n \"attachments\": [\n {}\n ],\n \"sender\": \"\",\n \"replyTo\": \"\",\n \"inReplyTo\": \"\",\n \"references\": \"\",\n \"attachDataUrls\": true,\n \"watchHtml\": \"\",\n \"amp\": \"\",\n \"icalEvent\": {},\n \"alternatives\": [\n {}\n ],\n \"encoding\": \"\",\n \"raw\": \"\",\n \"textEncoding\": \"quoted-printable\",\n \"priority\": \"high\",\n \"headers\": {\"ANY_ADDITIONAL_PROPERTY\": \"anything\"},\n \"messageId\": \"\",\n \"date\": \"\",\n \"list\": {},\n \"requireTLS\": true\n}")
req, _ := http.NewRequest("POST", url, bytes.NewReader(payload))
req.SetBasicAuth(config.ForwardEmailAPIToken, "")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
log.Info().Str("status", res.Status).Str("request_body", string(payload)).Str("response_body", string(body)).Msg("Attempted to send email")
return nil
}

58
comms/sms.go Normal file
View file

@ -0,0 +1,58 @@
package comms
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"github.com/Gleipnir-Technology/nidus-sync/config"
"github.com/rs/zerolog/log"
)
var VOIP_MS_API = "https://voip.ms/api/v1/rest.php"
type SendSMSResponse struct {
Status string `json:"status"`
SMS int `json:"sms"`
}
func SendSMS(to string, content string) error {
if len(content) > 160 {
return errors.New("Message content is more than 160 characters")
}
params := url.Values{}
params.Add("api_password", config.VoipMSPassword)
params.Add("api_username", config.VoipMSUsername)
params.Add("method", "sendSMS")
params.Add("did", config.VoipMSNumber)
params.Add("dst", to)
params.Add("message", content)
// Construct the URL with query parameters
full_url := VOIP_MS_API + "?" + params.Encode()
// Make the HTTP request
resp, err := http.Get(full_url)
if err != nil {
log.Warn().Err(err).Str("url", full_url).Msg("Failed to make request to Voip.MS")
return fmt.Errorf("Error making request: %w", err)
}
defer resp.Body.Close()
// Read the response body
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Warn().Err(err).Str("url", full_url).Msg("Failed to read Voip.MS response body")
return fmt.Errorf("Failed to read response: %w", err)
}
log.Info().Str("response", string(body)).Msg("Response from Voip.MS")
// Parse the JSON response
var response SendSMSResponse
err = json.Unmarshal(body, &response)
if err != nil {
return fmt.Errorf("Failed to unmarshal JSON response: %w", err)
}
return nil
}

View file

@ -7,7 +7,26 @@ import (
"strconv"
)
var Bind, ClientID, ClientSecret, Environment, FilesDirectoryPublic, FilesDirectoryUser, FieldseekerSchemaDirectory, MapboxToken, PGDSN, URLReport, URLSync, URLTegola string
var (
Bind string
ClientID string
ClientSecret string
Environment string
FilesDirectoryPublic string
FilesDirectoryUser string
FieldseekerSchemaDirectory string
ForwardEmailAPIToken string
ForwardEmailReportPassword string
ForwardEmailReportUsername string
MapboxToken string
PGDSN string
URLReport string
URLSync string
URLTegola string
VoipMSPassword string
VoipMSNumber string
VoipMSUsername string
)
// Build the ArcGIS authorization URL with PKCE
func BuildArcGISAuthURL(clientID string) string {
@ -43,6 +62,10 @@ func MakeURLSync(path string) string {
}
func Parse() error {
Bind = os.Getenv("BIND")
if Bind == "" {
Bind = ":9001"
}
ClientID = os.Getenv("ARCGIS_CLIENT_ID")
if ClientID == "" {
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_ID")
@ -51,22 +74,6 @@ func Parse() error {
if ClientSecret == "" {
return fmt.Errorf("You must specify a non-empty ARCGIS_CLIENT_SECRET")
}
URLReport = os.Getenv("URL_REPORT")
if URLReport == "" {
return fmt.Errorf("You must specify a non-empty URL_REPORT")
}
URLSync = os.Getenv("URL_SYNC")
if URLSync == "" {
return fmt.Errorf("You must specify a non-empty URL_SYNC")
}
URLTegola = os.Getenv("URL_TEGOLA")
if URLTegola == "" {
return fmt.Errorf("You must specify a non-empty URL_TEGOLA")
}
Bind = os.Getenv("BIND")
if Bind == "" {
Bind = ":9001"
}
Environment = os.Getenv("ENVIRONMENT")
if Environment == "" {
return fmt.Errorf("You must specify a non-empty ENVIRONMENT")
@ -74,14 +81,6 @@ func Parse() error {
if !(Environment == "PRODUCTION" || Environment == "DEVELOPMENT") {
return fmt.Errorf("ENVIRONMENT should be either DEVELOPMENT or PRODUCTION")
}
MapboxToken = os.Getenv("MAPBOX_TOKEN")
if MapboxToken == "" {
return fmt.Errorf("You must specify a non-empty MAPBOX_TOKEN")
}
PGDSN = os.Getenv("POSTGRES_DSN")
if PGDSN == "" {
return fmt.Errorf("You must specify a non-empty POSTGRES_DSN")
}
FieldseekerSchemaDirectory = os.Getenv("FIELDSEEKER_SCHEMA_DIRECTORY")
if FieldseekerSchemaDirectory == "" {
return fmt.Errorf("You must specify a non-empty FIELDSEEKER_SCHEMA_DIRECTORY")
@ -94,6 +93,50 @@ func Parse() error {
if FilesDirectoryUser == "" {
return fmt.Errorf("You must specify a non-empty FILES_DIRECTORY_USER")
}
ForwardEmailAPIToken = os.Getenv("FORWARDEMAIL_API_TOKEN")
if ForwardEmailAPIToken == "" {
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_API_TOKEN")
}
ForwardEmailReportUsername = os.Getenv("FORWARDEMAIL_REPORT_USERNAME")
if ForwardEmailReportUsername == "" {
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_REPORT_USERNAME")
}
ForwardEmailReportPassword = os.Getenv("FORWARDEMAIL_REPORT_PASSWORD")
if ForwardEmailReportPassword == "" {
return fmt.Errorf("You must specify a non-empty FORWARDEMAIL_REPORT_PASSWORD")
}
MapboxToken = os.Getenv("MAPBOX_TOKEN")
if MapboxToken == "" {
return fmt.Errorf("You must specify a non-empty MAPBOX_TOKEN")
}
PGDSN = os.Getenv("POSTGRES_DSN")
if PGDSN == "" {
return fmt.Errorf("You must specify a non-empty POSTGRES_DSN")
}
URLReport = os.Getenv("URL_REPORT")
if URLReport == "" {
return fmt.Errorf("You must specify a non-empty URL_REPORT")
}
URLSync = os.Getenv("URL_SYNC")
if URLSync == "" {
return fmt.Errorf("You must specify a non-empty URL_SYNC")
}
URLTegola = os.Getenv("URL_TEGOLA")
if URLTegola == "" {
return fmt.Errorf("You must specify a non-empty URL_TEGOLA")
}
VoipMSNumber = os.Getenv("VOIPMS_NUMBER")
if VoipMSNumber == "" {
return fmt.Errorf("You must specify a non-empty VOIPMS_NUMBER")
}
VoipMSPassword = os.Getenv("VOIPMS_PASSWORD")
if VoipMSPassword == "" {
return fmt.Errorf("You must specify a non-empty VOIPMS_PASSWORD")
}
VoipMSUsername = os.Getenv("VOIPMS_USERNAME")
if VoipMSUsername == "" {
return fmt.Errorf("You must specify a non-empty VOIPMS_USERNAME")
}
return nil
}

View file

@ -1,14 +1,10 @@
package publicreport
import (
"fmt"
"net/http"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/htmlpage"
"github.com/rs/zerolog/log"
"github.com/stephenafamo/bob/dialect/psql"
"github.com/stephenafamo/bob/dialect/psql/um"
)
type ContextRegisterNotificationsComplete struct {
@ -29,52 +25,6 @@ func getRoot(w http.ResponseWriter, r *http.Request) {
)
}
func getRegisterNotificationsComplete(w http.ResponseWriter, r *http.Request) {
report := r.URL.Query().Get("report")
htmlpage.RenderOrError(
w,
RegisterNotificationsComplete,
ContextRegisterNotificationsComplete{
ReportID: report,
},
)
}
func postRegisterNotifications(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
respondError(w, "Failed to parse form", err, http.StatusBadRequest)
return
}
consent := r.PostFormValue("consent")
email := r.PostFormValue("email")
phone := r.PostFormValue("phone")
report_id := r.PostFormValue("report_id")
if consent != "on" {
respondError(w, "You must consent", nil, http.StatusBadRequest)
return
}
result, err := psql.Update(
um.Table("publicreport.quick"),
um.SetCol("reporter_email").ToArg(email),
um.SetCol("reporter_phone").ToArg(phone),
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
).Exec(r.Context(), db.PGInstance.BobDB)
if err != nil {
respondError(w, "Failed to update report", err, http.StatusInternalServerError)
return
}
rowcount, err := result.RowsAffected()
if err != nil {
respondError(w, "Failed to get rows affected", err, http.StatusInternalServerError)
return
}
if rowcount == 0 {
http.Redirect(w, r, fmt.Sprintf("/error?code=no-rows-affected&report=%s", report_id), http.StatusFound)
} else {
http.Redirect(w, r, fmt.Sprintf("/register-notifications-complete?report=%s", report_id), http.StatusFound)
}
}
// Respond with an error that is visible to the user
func respondError(w http.ResponseWriter, m string, e error, s int) {
log.Warn().Int("status", s).Err(e).Str("user message", m).Msg("Responding with an error")

View file

@ -6,6 +6,7 @@ import (
"strconv"
"time"
"github.com/Gleipnir-Technology/nidus-sync/comms"
"github.com/Gleipnir-Technology/nidus-sync/db"
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
"github.com/Gleipnir-Technology/nidus-sync/db/models"
@ -46,6 +47,16 @@ func getQuickSubmitComplete(w http.ResponseWriter, r *http.Request) {
},
)
}
func getRegisterNotificationsComplete(w http.ResponseWriter, r *http.Request) {
report := r.URL.Query().Get("report")
htmlpage.RenderOrError(
w,
RegisterNotificationsComplete,
ContextRegisterNotificationsComplete{
ReportID: report,
},
)
}
func postQuick(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(32 << 10) // 32 MB buffer
if err != nil {
@ -134,3 +145,57 @@ func postQuick(w http.ResponseWriter, r *http.Request) {
tx.Commit(ctx)
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", u), http.StatusFound)
}
func postRegisterNotifications(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
respondError(w, "Failed to parse form", err, http.StatusBadRequest)
return
}
consent := r.PostFormValue("consent")
email := r.PostFormValue("email")
phone := r.PostFormValue("phone")
report_id := r.PostFormValue("report_id")
if consent != "on" {
respondError(w, "You must consent", nil, http.StatusBadRequest)
return
}
if email == "" && phone == "" {
http.Redirect(w, r, fmt.Sprintf("/quick-submit-complete?report=%s", report_id), http.StatusFound)
return
}
result, err := psql.Update(
um.Table("publicreport.quick"),
um.SetCol("reporter_email").ToArg(email),
um.SetCol("reporter_phone").ToArg(phone),
um.Where(psql.Quote("public_id").EQ(psql.Arg(report_id))),
).Exec(r.Context(), db.PGInstance.BobDB)
if err != nil {
respondError(w, "Failed to update report", err, http.StatusInternalServerError)
return
}
rowcount, err := result.RowsAffected()
if err != nil {
respondError(w, "Failed to get rows affected", err, http.StatusInternalServerError)
return
}
if email != "" {
comms.SendEmail(comms.EmailRequest{
From: "website@mosquitoes.online",
To: email,
Subject: "test email",
Text: "This is just testing that I can send email",
})
}
if phone != "" {
err := comms.SendSMS(phone, "testing 1 2 3")
if err != nil {
log.Error().Err(err).Msg("Failed to send SMS")
}
}
if rowcount == 0 {
http.Redirect(w, r, fmt.Sprintf("/error?code=no-rows-affected&report=%s", report_id), http.StatusFound)
} else {
http.Redirect(w, r, fmt.Sprintf("/register-notifications-complete?report=%s", report_id), http.StatusFound)
}
}