From 2acac513f807dd42689b8645303e146d1da8876e Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Wed, 1 Oct 2025 16:41:57 -0600 Subject: [PATCH] implemented the E-mail sending functionality --- config/config.go | 16 ++- config/default.yaml | 6 + email/message.go | 97 +++++++++++-- email/sender.go | 220 ++++++++++++++++++++++++++++++ email/templates/email_confirm.jet | 13 ++ email/templates/pass_change.jet | 12 ++ email/templates/pass_remind.jet | 20 +++ login.go | 18 ++- main.go | 3 + util/freelist.go | 67 +++++++++ 10 files changed, 449 insertions(+), 23 deletions(-) create mode 100644 email/sender.go create mode 100644 email/templates/email_confirm.jet create mode 100644 email/templates/pass_change.jet create mode 100644 email/templates/pass_remind.jet create mode 100644 util/freelist.go diff --git a/config/config.go b/config/config.go index 878a7bb..5ef83b1 100644 --- a/config/config.go +++ b/config/config.go @@ -59,12 +59,14 @@ type AmConfig struct { Dsn string `yaml:"dsn"` } `yaml:"database"` Email struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - Tls string `yaml:"tls"` - AuthType string `yaml:"authType"` - User string `yaml:"user"` - Password string `yaml:"password"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Tls string `yaml:"tls"` + AuthType string `yaml:"authType"` + User string `yaml:"user"` + Password string `yaml:"password"` + Signature string `yaml:"signature"` + Disclaimer string `yaml:"disclaimer"` } `yaml:"email"` Rendering struct { TemplateDir string `yaml:"templatedir"` @@ -128,6 +130,8 @@ func overlayConfig(dest *AmConfig, loaded *AmConfig, defaults *AmConfig) { dest.Email.AuthType = overlayString(loaded.Email.AuthType, defaults.Email.AuthType) dest.Email.User = overlayString(loaded.Email.User, defaults.Email.User) dest.Email.Password = overlayString(loaded.Email.Password, defaults.Email.Password) + dest.Email.Signature = overlayString(loaded.Email.Signature, defaults.Email.Signature) + dest.Email.Disclaimer = overlayString(loaded.Email.Disclaimer, defaults.Email.Disclaimer) dest.Rendering.TemplateDir = overlayString(loaded.Rendering.TemplateDir, defaults.Rendering.TemplateDir) dest.Rendering.CookieKey = overlayString(loaded.Rendering.CookieKey, defaults.Rendering.CookieKey) } diff --git a/config/default.yaml b/config/default.yaml index af4f5e1..9cd9d3d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -23,6 +23,12 @@ email: authType: plain user: jrn password: foobiebletch + signature: |- + Amsterdam - community services, conferencing and more. + disclaimer: |- + Message sent via Amsterdam Web Communities System - + The Amsterdam Project is not responsible for the contents of this message + Report abuses to: rendering: templatedir: custom_templates cookiekey: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz diff --git a/email/message.go b/email/message.go index 611dcfe..3911281 100644 --- a/email/message.go +++ b/email/message.go @@ -10,7 +10,12 @@ // Package email contains support for E-mail messages sent by Amsterdam. package email -import "fmt" +import ( + "fmt" + + "git.erbosoft.com/amy/amsterdam/util" + "github.com/CloudyKit/jet/v6" +) // Message is the interface for an E-mail message to be sent. type Message interface { @@ -21,18 +26,32 @@ type Message interface { SetSubject(string) SetText(string) AddHeader(string, string) + SetTemplate(string) + AddVariable(string, any) + Send() } +// amMessage is the internal structure of the Message. type amMessage struct { - from string - to []string - cc []string - bcc []string - subject string - text string - headers map[string]string + from string + fromAddr string + to []string + toAddrs []string + cc []string + bcc []string + subject string + text string + headers map[string]string + template string + vars jet.VarMap + uid int32 + ip string } +// freeMessages is a free list for amMessage structures. +var freeMessages util.FreeList[amMessage] + +// formatAddress outputs an E-mail address with optional name associated with it. func formatAddress(addr string, name string) string { if name == "" { return addr @@ -41,35 +60,91 @@ func formatAddress(addr string, name string) string { } } +// SetFrom sets the From: address of the message. func (m *amMessage) SetFrom(addr string, name string) { m.from = formatAddress(addr, name) + m.fromAddr = addr } +// AddTo ads a To: address to the message. func (m *amMessage) AddTo(addr string, name string) { m.to = append(m.to, formatAddress(addr, name)) + m.toAddrs = append(m.toAddrs, addr) } +// AddCC ads a Cc: address to the message. func (m *amMessage) AddCC(addr string, name string) { m.cc = append(m.cc, formatAddress(addr, name)) + m.toAddrs = append(m.toAddrs, addr) } +// AddBCC ads a Bcc: address to the message. func (m *amMessage) AddBCC(addr string, name string) { m.bcc = append(m.bcc, formatAddress(addr, name)) + m.toAddrs = append(m.toAddrs, addr) } +// SetSubject sets the message's subject. func (m *amMessage) SetSubject(s string) { m.subject = s } +// SetText sets the text of the message. func (m *amMessage) SetText(txt string) { m.text = txt } +// AddHaader adds a new header to the message. func (m *amMessage) AddHeader(name string, value string) { m.headers[name] = value } -func AmNewEmailMessage() Message { - rc := amMessage{to: make([]string, 0), cc: make([]string, 0), bcc: make([]string, 0), headers: make(map[string]string)} - return &rc +func (m *amMessage) SetTemplate(templ string) { + m.template = templ +} + +func (m *amMessage) AddVariable(name string, value any) { + m.vars.Set(name, value) +} + +func (m *amMessage) Send() { + sendChan <- m +} + +/* AmNewEmailMessage creates a new message and returns it. + * Parameters: + * sender = User ID of the person sending the message. + * ip = IP address of the person sending the message. + * Returns: + * The new Message. + */ +func AmNewEmailMessage(sender int32, ip string) Message { + rc := freeMessages.Get() + if rc == nil { + rc = &amMessage{to: make([]string, 0), cc: make([]string, 0), bcc: make([]string, 0), + headers: make(map[string]string)} + } + rc.uid = sender + rc.ip = ip + return rc +} + +// recycleMessage cleans out a message and puts it back on the free list. +func recycleMessage(m *amMessage) { + m.from = "" + m.fromAddr = "" + m.to = make([]string, 0) + m.toAddrs = make([]string, 0) + m.cc = make([]string, 0) + m.bcc = make([]string, 0) + m.subject = "" + m.text = "" + for k := range m.headers { + delete(m.headers, k) + } + m.template = "" + for k := range m.vars { + delete(m.vars, k) + } + freeMessages.Put(m) } diff --git a/email/sender.go b/email/sender.go new file mode 100644 index 0000000..b424bf2 --- /dev/null +++ b/email/sender.go @@ -0,0 +1,220 @@ +/* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// Package email contains support for E-mail messages sent by Amsterdam. +package email + +import ( + "bytes" + "embed" + "fmt" + "io" + "maps" + "net/smtp" + "os" + "slices" + "strings" + + "git.erbosoft.com/amy/amsterdam/config" + "git.erbosoft.com/amy/amsterdam/database" + "github.com/CloudyKit/jet/v6" + "github.com/CloudyKit/jet/v6/loaders/embedfs" + log "github.com/sirupsen/logrus" +) + +//go:embed templates/* +var emailTemplates embed.FS + +// email_renderer is a separate Jet instance for making E-mail messages. +var emailRenderer *jet.Set + +// disclaimerLines is the disclaimer from the configuration broken into lines. +var disclaimerLines []string + +// signatureLines is the signature from the configuration broken into lines. +var signatureLines []string + +// The mail host and port. +var mailHost string + +// The SMTP authentication to use. +var auth smtp.Auth + +// formatMessage takes a message and turns it into serialized bytes for sending. +func formatMessage(m *amMessage) ([]byte, error) { + if m.template != "" { + // Render the template for the message, which may reset Subject. + templ, err := emailRenderer.GetTemplate(m.template) + if err == nil { + var buf bytes.Buffer + err = templ.Execute(&buf, m.vars, Message(m)) + if err == nil { + m.text = buf.String() + } + } + if err != nil { + return make([]byte, 0), err + } + } + user, err := database.AmGetUser(m.uid) + if err == nil { + // Build the final headers. + hdrs := make(map[string]string) + maps.Copy(hdrs, m.headers) + hdrs["From"] = m.from + hdrs["To"] = strings.Join(m.to, ", ") + hdrs["Cc"] = strings.Join(m.cc, ", ") + hdrs["Bcc"] = strings.Join(m.bcc, ", ") + hdrs["Subject"] = m.subject + hdrs["Content-Type"] = "text/plain; charset=UTF-8" + me, _ := os.Hostname() + hdrs["X-Amsterdam-Server-Info"] = fmt.Sprintf("%s (Amsterdam/%s)", me, config.AMSTERDAM_VERSION) + hdrs["X-Amsterdam-Sender-Info"] = fmt.Sprintf("uid %d, name %s, ip [%s]", m.uid, user.Username, m.ip) + for i, v := range disclaimerLines { + hdrs[fmt.Sprintf("X-Disclaimer-%d", i)] = v + } + + // Sort the header keys tro make for a better presentation. + keys := make([]string, 0, len(hdrs)) + for k := range hdrs { + keys = append(keys, k) + } + slices.Sort(keys) + + // Build the actual message. + var out bytes.Buffer + for _, k := range keys { + fmt.Fprintf(&out, "%s: %s\r\n", k, hdrs[k]) + } + out.WriteString("\r\n") + for _, l := range strings.Split(m.text, "\n") { + fmt.Fprintf(&out, "%s\r\n", l) + } + out.WriteString("--\r\n") + for _, l := range signatureLines { + fmt.Fprintf(&out, "%s\r\n", l) + } + return out.Bytes(), nil + } + return make([]byte, 0), err +} + +// transmitMessage handles the sending of the message. +func transmitMessage(m *amMessage, body []byte) { + cl, err := smtp.Dial(mailHost) + if err == nil { + defer cl.Close() + me, _ := os.Hostname() + if err = cl.Hello(me); err == nil { + if config.GlobalConfig.Email.Tls == "starttls" { + if ok, _ := cl.Extension("STARTTLS"); ok { + err = cl.StartTLS(nil) + } else { + log.Infof("server %s does not support STARTTLS", mailHost) + } + } + if err == nil { + if err = cl.Auth(auth); err == nil { + if err = cl.Mail(m.fromAddr); err == nil { + for _, addr := range m.toAddrs { + if err = cl.Rcpt(addr); err != nil { + log.Errorf("failed to set recipient address: %v", err) + break + } + } + if err == nil { + var w io.WriteCloser + w, err = cl.Data() + if err == nil { + _, err = w.Write(body) + if err != nil { + log.Errorf("failed to write message data: %v", err) + } + err = w.Close() + if err != nil { + log.Errorf("failed to close and send: %v", err) + } + err = cl.Quit() + if err != nil { + log.Errorf("failed to quit session: %v", err) + } + } else { + log.Errorf("failed to start writing data: %v", err) + } + } + } else { + log.Errorf("failed to set sender: %v", err) + } + } else { + log.Errorf("failed to authenticate to server: %v", err) + } + } else { + log.Errorf("failed to start TLS handshake: %v", err) + } + } else { + log.Errorf("error sending HELO to server: %v", err) + } + } else { + log.Errorf("unable to contact host %s via SMTP: %v", mailHost, err) + } +} + +// senderLoop collects E-mail messages from the channel and pushes them out. +func senderLoop(sent chan *amMessage, done chan bool) { + for m := range sent { + body, err := formatMessage(m) + if err == nil { + transmitMessage(m, body) + } else { + log.Errorf("unable to format message: %v", err) + } + go recycleMessage(m) + } + done <- true // signal done for synchronization +} + +// sendChan is the channel we put E-mail messages on to be sent. +var sendChan chan *amMessage + +// doneChan is the channel that gets signaled when the senderLoop breaks. +var doneChan chan bool + +// SetupMailSender starts the mail-sending goroutine. +func SetupMailSender() { + // Initialize mail host and authentication. + mailHost = fmt.Sprintf("%s:%d", config.GlobalConfig.Email.Host, config.GlobalConfig.Email.Port) + switch config.GlobalConfig.Email.AuthType { + case "plain": + auth = smtp.PlainAuth("", config.GlobalConfig.Email.User, config.GlobalConfig.Email.Password, + config.GlobalConfig.Email.Host) + default: + panic("Unknown auth type: " + config.GlobalConfig.Email.AuthType) + } + + // Split the configured disclaimer and signature. + disclaimerLines = strings.Split(config.GlobalConfig.Email.Disclaimer, "\n") + signatureLines = strings.Split(config.GlobalConfig.Email.Signature, "\n") + + // Initialize the template engine. + emailRenderer = jet.NewSet( + embedfs.NewLoader("templates/", emailTemplates), + jet.DevelopmentMode(true), + ) + + // Start the sender loop. + sendChan = make(chan *amMessage, 16) + doneChan = make(chan bool) + go senderLoop(sendChan, doneChan) +} + +// EndMailServer shuts down the mail-sending goroutine. +func EndMailServer() { + close(sendChan) // will break the loop in senderLoop + <-doneChan // wait for routine to complete +} diff --git a/email/templates/email_confirm.jet b/email/templates/email_confirm.jet new file mode 100644 index 0000000..1221c58 --- /dev/null +++ b/email/templates/email_confirm.jet @@ -0,0 +1,13 @@ +{{ .SetSubject("Amsterdam Email Confirmation") }} +Welcome to the Amsterdam conferencing system! In order to fully activate your +account after you register or change your E-mail address, you must provide a +confirmation number to the system. Please enter this number into the "Confirm +E-mail Address" dialog on the system when indicated. + +Your confirmation number for your account "{{ username }}" is {{ confnum }}. + +Access the E-mail verification dialog at . + +Thank you, and enjoy the Amsterdam conferencing system! + +- The Management diff --git a/email/templates/pass_change.jet b/email/templates/pass_change.jet new file mode 100644 index 0000000..802d8fb --- /dev/null +++ b/email/templates/pass_change.jet @@ -0,0 +1,12 @@ +{{ .SetSubject("Amsterdam Password Changed") }} +The password for your account "{{ username }}" has been changed. The new +password is "{{ password }}". + +You should log into Amsterdam immediately and change the password to something +else. You can change the password for your account, once you are logged in, +by clicking on the "Profile" link in the top bar. + +If you did NOT request a password change on your account, please notify the +system administrator IMMEDIATELY. + +- The Management diff --git a/email/templates/pass_remind.jet b/email/templates/pass_remind.jet new file mode 100644 index 0000000..cb4b7af --- /dev/null +++ b/email/templates/pass_remind.jet @@ -0,0 +1,20 @@ +{{ .SetSubject("Amsterdam Password Reminder Message") }} +Here is the password reminder for your account "{{ username }}" as you requested: + +{{ reminder }} + +If this reminder is not sufficient for you to remember what your password is, +then the system can change your password for you. To do so, please visit +the following URL: + +http://example.com/passrecovery/{{ change_uid }}.{{ change.auth }} + +Your password will be changed and a new password will be E-mailed to you +at this address. + +If you did NOT request a password reminder, then this message was sent +by someone attempting to access your account without your knowledge. Do +not panic! Nothing has happened to your account or password yet, but +please do notify the system administrator. + +- The Management diff --git a/login.go b/login.go index 997f6bd..cc13054 100644 --- a/login.go +++ b/login.go @@ -10,7 +10,6 @@ package main import ( - "errors" "fmt" "git.erbosoft.com/amy/amsterdam/database" @@ -67,15 +66,21 @@ func Login(ctxt ui.AmContext) (string, any, error) { } action := dlg.WhichButton(ctxt) - if action == "cancel" { + if action == "cancel" { // Cancel button pressed return "redirect", target, nil } - if action == "remind" { - // TODO: send password reminder + if action == "remind" { // Password Reminder button pressed + user, uerr := database.AmGetUserByName(dlg.Field("user").Value) + if uerr == nil { + _ = user + // TODO: send password reminder + + } + dlg.Field("pass").Value = "" return dlg.RenderError(ctxt, "Password reminder has been sent to your E-mail address.") } - if action == "login" { + if action == "login" { // Login button pressed // authenticate the user user, uerr := database.AmAuthenticateUser(dlg.Field("user").Value, dlg.Field("pass").Value, ctxt.RemoteIP()) if uerr != nil { @@ -89,7 +94,8 @@ func Login(ctxt ui.AmContext) (string, any, error) { // TODO: bounce to E-mail verify if we can do so return "redirect", target, nil } - err = errors.New("no known button click on POST to login function") + dlg.Field("pass").Value = "" + return dlg.RenderError(ctxt, "No known button click on POST to login function.") } return ui.ErrorPage(ctxt, err) } diff --git a/main.go b/main.go index 71ee29c..5c872da 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,7 @@ import ( "git.erbosoft.com/amy/amsterdam/config" "git.erbosoft.com/amy/amsterdam/database" + "git.erbosoft.com/amy/amsterdam/email" "git.erbosoft.com/amy/amsterdam/ui" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -60,6 +61,8 @@ func main() { panic(fmt.Sprintf("Database open failure: %v", err)) } defer database.ClosedownDb() + email.SetupMailSender() + defer email.EndMailServer() ui.SetupTemplates() ui.SetupSessionManager() ui.SetupLeftMenus() diff --git a/util/freelist.go b/util/freelist.go new file mode 100644 index 0000000..b41c4a4 --- /dev/null +++ b/util/freelist.go @@ -0,0 +1,67 @@ +/* + * Amsterdam Web Communities System + * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + +// Package util contains utility definitions. +package util + +import "sync" + +// freeListElem is an element of the free list. +type freeListElem[T any] struct { + next *freeListElem[T] + prev *freeListElem[T] + data *T +} + +// FreeList defines a free list. +type FreeList[T any] struct { + New func() *T + mutex sync.Mutex + listptr *freeListElem[T] +} + +// Put adds a value to the free list. +func (l *FreeList[T]) Put(value *T) { + l.mutex.Lock() + defer l.mutex.Unlock() + ne := freeListElem[T]{data: value} + if l.listptr == nil { + ne.next = &ne + ne.prev = &ne + l.listptr = &ne + } else { + ne.next = l.listptr + ne.prev = l.listptr.prev + ne.next.prev = &ne + ne.prev.next = &ne + } +} + +// Get removes a value from the free list. If there are no values and New is specified, is calls that. +func (l *FreeList[T]) Get() *T { + l.mutex.Lock() + defer l.mutex.Unlock() + var rc *T = nil + if l.listptr == nil { + if l.New != nil { + rc = l.New() + } + } else { + elt := l.listptr + rc = elt.data + l.listptr = elt.next + if l.listptr == elt { + l.listptr = nil + } else { + elt.prev.next = elt.next + elt.next.prev = elt.prev + } + } + return rc +}