diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2fdc5c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © 2024 NAME HERE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/assemble.go b/assemble.go new file mode 100644 index 0000000..5dd8600 --- /dev/null +++ b/assemble.go @@ -0,0 +1,55 @@ +package mailer + +import "fmt" + +/* + use AddTemplate and AddKeyedTemplate to make repeatable emails +*/ + +// Template creates a repeatable template of your subject/body +func (m Mailer) AddTemplate(name string, subject string, body string) { + m.Send[name] = func(user string) { + m.sendNoKey(user, subject, body) + } +} + +// TemplateKeyed returns a function that sends keyed emails +func (m Mailer) AddKeyedTemplate(name, subject, explanation, baseurl, notyou string) { + m.SendKeyed[name] = func(username string, code string) { + m.sendKeyed(username, subject, explanation, baseurl, code, notyou) + } +} + +// pasting body together +func (m Mailer) sendKeyed(username string, subject string, explanation string, baseurl string, code string, notyou string) { + fmt.Println("send keyed: " + subject + " " + code + " to " + username) + message := explanation + "\n" + + baseurl + code + "\n\n" + + notyou + "\n\n" + msg := m.assemble(username, subject, message) + m.internal_mail(username, msg) +} + +func (m Mailer) sendNoKey(username string, subject string, body string) { + message := body + "\n\n" + msg := m.assemble(username, subject, message) + m.internal_mail(username, msg) +} + +// asemble adds the unsubscribe codes and footer to an email +func (m Mailer) assemble(to string, subj string, message string) []byte { + code := m.UnsubscribeCodeFn(to) + msg := []byte("To: " + to + "\r\n" + + "From: " + m.From + "\r\n" + + "Subject: " + subj + "\r\n" + + "List-Unsubscribe post: List-Unsubscribe=One click; \r\n" + + "List-Unsubscribe: <" + m.UnsubscribeUrl + code + ">\r\n" + + "\r\n" + + message + + m.Footer + + "If you want to unsubscribe, reply to this email or use this link:\n\n" + + m.UnsubscribeUrl + code + "\n\n" + + "\r\n" + "\r\n", + ) + return msg +} diff --git a/delay/delay.go b/delay/delay.go new file mode 100644 index 0000000..52a07ed --- /dev/null +++ b/delay/delay.go @@ -0,0 +1,133 @@ +package delay + +import ( + "errors" + "fmt" + "sync/atomic" + "time" +) + +var GLOBAL_MAILCOUNT *mailqueue + +type mailer struct { + subject string + to string + body string +} + +// probably easier to do this as sqlite storage selections +type mailqueue struct { + dirty atomic.Bool + size int + instant chan mailer // priority zero + reminder chan mailer // priority one + maintenance chan mailer // priority two + marketing chan mailer // priority 3 + errors int + maxerrors int +} + +func init() { + var err error + GLOBAL_MAILCOUNT, err = NewMailqueue(100, 60, 90, 5) + if err != nil { + fmt.Println(err) + } +} +func NewMailqueue(size int, resetseconds int, sendseconds int, maxerrors int) (*mailqueue, error) { + m := mailqueue{ + size: size, + instant: make(chan mailer, size), + reminder: make(chan mailer, size), + maintenance: make(chan mailer, size), + marketing: make(chan mailer, size), + maxerrors: maxerrors, + } + if resetseconds < 60 { + return nil, errors.New("too small of a value") + } + if sendseconds < 90 { + return nil, errors.New("too large of a value") + } + go m.BucketDrip(resetseconds) + go m.BackgroundMail(sendseconds) + return &m, nil +} +func (m *mailqueue) BucketDrip(resetseconds int) { + // every 60 seconds + for range time.Tick(time.Second * time.Duration(resetseconds)) { + m.dirty.Store(false) + } +} + +func (m *mailqueue) BackgroundMail(sendseconds int) { + // every 90 seconds (.75*60*24, 1080) + for range time.Tick(time.Second * time.Duration(sendseconds)) { + if m.errors > m.maxerrors { + fmt.Println("too many errors, not sending.") + return + } + var err error + if m.dirty.Load() != true { + select { + case e := <-m.maintenance: + m.dirty.Store(true) + err = Send(e) + default: + select { + case e := <-m.maintenance: + m.dirty.Store(true) + err = Send(e) + case e := <-m.reminder: + m.dirty.Store(true) + err = Send(e) + default: + select { + case e := <-m.maintenance: + m.dirty.Store(true) + err = Send(e) + case e := <-m.reminder: + m.dirty.Store(true) + err = Send(e) + case e := <-m.marketing: + m.dirty.Store(true) + err = Send(e) + default: + fmt.Println("queue empty") + } + } + } + } + if err != nil { + // add to failed queue? store as failed? + m.errors += 1 + } + err = nil + } +} +func (m *mailqueue) AddSend(mail mailer, queue string) error { + var target chan mailer + switch queue { + case "maintenance": + target = m.maintenance + case "reminder": + target = m.reminder + case "marketing": + target = m.marketing + default: + return errors.New("no valid queue") + } + if m.size-1 < len(target) { + return errors.New("mail queue full!") + } + select { + case target <- mail: + default: + return errors.New("mail queue full!") + } + return nil +} +func Send(m mailer) error { + fmt.Println(m.to, m.subject, m.body) + return nil +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..dcf553b --- /dev/null +++ b/example_test.go @@ -0,0 +1,49 @@ +package mailer_test + +import ( + "crypto/sha256" + "fmt" + + "git.bivouac.wiki/use/mailer" +) + +// UnsubFunc creates a code for the user to manage their email preferences +func UnsubFunc(user string) string { + // you could also just as well store random UUIDs in a KV... + salt := "example_code" + h := sha256.New() + // the important part is not being able to reverse the username from the code + h.Write([]byte(user + salt)) + hash := fmt.Sprintf("%x", h.Sum(nil)) + return hash +} + +func ExampleMailer() { + from := "app@ornithopterhosting.com" + pass := "password1234" + host := "mail.ornithopterhosting.com" + m := mailer.New(from, + pass, + host, + 465, + "https://app.ornithopter.me/unsubscribe/", + "If you have problems contact cameron@ornithopterhosting.com", + UnsubFunc, + ) + // build methods + notyou := "If this wasn't you, please email us at support@ornithopterhosting.com" + register := "Welcome! Click this link to confirm your account:" + registerbase := "https://app.ornithopter.me/auth/register/" + m.AddKeyedTemplate("Register", "Registration", register, registerbase, notyou) + recoveryexplain := "Someone (probably you) requested a password reset.\nTo finish recovering your account, click here:" + recoverybase := "https://app.ornithopter.me/auth/recover/" + m.AddKeyedTemplate("Recovery", "Password Recovery", recoveryexplain, recoverybase, notyou) + passwordchanged := "Someone (probably you) changed your password.\n" + + "If this wasn't you, please email us at support@ornithopterhosting.com\n" + + m.AddTemplate("Changed", "Password Changed", passwordchanged) + // send with methods + m.SendKeyed["Register"]("user@foobar.com", "1234") + m.SendKeyed["Recovery"]("user@foobar.com", "1234") + m.Send["Changed"]("user@foobar.com") +} diff --git a/init.go b/init.go new file mode 100644 index 0000000..0f5c2a1 --- /dev/null +++ b/init.go @@ -0,0 +1,37 @@ +package mailer + +import ( + "strconv" + "sync/atomic" +) + +type Mailer struct { + Password string + HostOnly string + HostWithPort string + From string + Footer string + UnsubscribeUrl string + UnsubscribeCodeFn UnsubFunc + Send map[string]func(email string) + SendKeyed map[string]func(email string, code string) + mail_counter *atomic.Uint64 + wanted_send *atomic.Uint64 +} + +// UnsubscribeFunc should take a username and create an opaque string +type UnsubFunc func(username string) string + +// New creates a mailer with your configured settings +func New(from string, password string, host string, port int, unsubscribe string, footer string, unsub UnsubFunc) Mailer { + p := strconv.FormatInt(int64(port), 10) + return Mailer{ + Password: password, // utils.GlobalConfig.SMTP_PASSWORD + From: from, + HostOnly: host, + HostWithPort: host + ":" + p, + Footer: footer, + UnsubscribeUrl: unsubscribe, + UnsubscribeCodeFn: unsub, + } +} diff --git a/internal_mail.go b/internal_mail.go new file mode 100644 index 0000000..1a7e1da --- /dev/null +++ b/internal_mail.go @@ -0,0 +1,68 @@ +package mailer + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strconv" +) + +// internal SMTP code, messy. +func (m Mailer) internal_mail(un string, msg []byte) { + m.wanted_send.Add(1) + wanted := m.wanted_send.Load() + mail_amt := m.mail_counter.Load() + fmt.Println("sent mail: " + strconv.Itoa(int(mail_amt))) + fmt.Println("wanted to send: " + strconv.Itoa(int(wanted))) + if mail_amt > 3000 { + fmt.Println("sending total too high.") + return + } + recip := []string{un} + auth := smtp.PlainAuth(m.From, m.From, m.Password, m.HostWithPort) + tlsconfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: m.HostOnly, + } + conn, err := tls.Dial("tcp", m.HostWithPort, tlsconfig) + if err != nil { + fmt.Println("dial failed") + return + } + client, err := smtp.NewClient(conn, m.HostWithPort) + if err != nil { + fmt.Println("new client failed") + return + } + if err = client.Auth(auth); err != nil { + fmt.Println("auth failed") + return + } + if err = client.Mail(m.From); err != nil { + fmt.Println("from failed") + return + } + client.Rcpt(recip[0]) + w, err := client.Data() + if err != nil { + fmt.Println("recip failed") + return + } + _, err = w.Write(msg) + if err != nil { + fmt.Println("open body failed") + return + } + err = w.Close() + if err != nil { + fmt.Println("close body failed") + return + } + err = client.Quit() + if err != nil { + fmt.Println("client quit failed") + return + } + fmt.Println("sent!") + m.mail_counter.Add(1) +} diff --git a/kv.go b/kv.go deleted file mode 100644 index 6e69163..0000000 --- a/kv.go +++ /dev/null @@ -1,3 +0,0 @@ -package mailer - -// kv interface (for unsubscribe links) diff --git a/smtp.go b/smtp.go deleted file mode 100644 index 7fd029f..0000000 --- a/smtp.go +++ /dev/null @@ -1,3 +0,0 @@ -package mailer - -// smtp code