implemented the E-mail sending functionality

This commit is contained in:
2025-10-01 16:41:57 -06:00
parent 1633492f29
commit 2acac513f8
10 changed files with 449 additions and 23 deletions
+10 -6
View File
@@ -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)
}
+6
View File
@@ -23,6 +23,12 @@ email:
authType: plain
user: jrn
password: foobiebletch
signature: |-
Amsterdam - community services, conferencing and more. <http://git.erbosoft.com/amy/amsterdam>
disclaimer: |-
Message sent via Amsterdam Web Communities System - <http://git.erbosoft.com/amy/amsterdam>
The Amsterdam Project is not responsible for the contents of this message
Report abuses to: <abuse@example.com>
rendering:
templatedir: custom_templates
cookiekey: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
+86 -11
View File
@@ -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)
}
+220
View File
@@ -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
}
+13
View File
@@ -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 <http://example.com/verifyemail>.
Thank you, and enjoy the Amsterdam conferencing system!
- The Management
+12
View File
@@ -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
+20
View File
@@ -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
+12 -6
View File
@@ -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)
}
+3
View File
@@ -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()
+67
View File
@@ -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
}