installer API: reference handler (internal/installapi), SMTP mailer with STARTTLS, plain+HTML code template, rate limits; verae-mailtest; user-token.ps1 validates via Go; stricter key masking
This commit is contained in:
parent
6e76bc7b0e
commit
1317bea604
4 changed files with 1249 additions and 5 deletions
266
internal/mailer/mailer.go
Normal file
266
internal/mailer/mailer.go
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
// Package mailer sends Verae verification-code emails over SMTP (STARTTLS).
|
||||
//
|
||||
// Configuration comes only from the environment (never from files in the repo):
|
||||
//
|
||||
// SMTP_HOST default smtp.gmail.com
|
||||
// SMTP_PORT default 587 (STARTTLS is required; 465 implicit TLS also works)
|
||||
// SMTP_USER e.g. veraetimestamp@gmail.com
|
||||
// SMTP_PASS Gmail app password (never logged; scrubbed from errors)
|
||||
// SMTP_FROM default "Verae TimeStamp <SMTP_USER>"
|
||||
//
|
||||
// The password is held only in memory and is scrubbed from every error string.
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds SMTP settings. Pass is unexported-by-convention: never print a Config.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Pass string
|
||||
From string
|
||||
// AllowInsecureLocal permits plaintext SMTP to 127.0.0.1/localhost (tests only).
|
||||
AllowInsecureLocal bool
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// String never reveals the password.
|
||||
func (c Config) String() string {
|
||||
p := "unset"
|
||||
if c.Pass != "" {
|
||||
p = "set"
|
||||
}
|
||||
return fmt.Sprintf("smtp://%s@%s:%d from=%q pass=%s", c.User, c.Host, c.Port, c.From, p)
|
||||
}
|
||||
|
||||
// FromEnv reads SMTP_* variables.
|
||||
func FromEnv() (Config, error) {
|
||||
c := Config{
|
||||
Host: envOr("SMTP_HOST", "smtp.gmail.com"),
|
||||
User: strings.TrimSpace(os.Getenv("SMTP_USER")),
|
||||
Pass: os.Getenv("SMTP_PASS"),
|
||||
From: strings.TrimSpace(os.Getenv("SMTP_FROM")),
|
||||
Timeout: 20 * time.Second,
|
||||
}
|
||||
port, err := strconv.Atoi(envOr("SMTP_PORT", "587"))
|
||||
if err != nil || port <= 0 || port > 65535 {
|
||||
return c, errors.New("SMTP_PORT is not a valid port")
|
||||
}
|
||||
c.Port = port
|
||||
if c.User == "" {
|
||||
return c, errors.New("SMTP_USER is not set")
|
||||
}
|
||||
if c.Pass == "" {
|
||||
return c, errors.New("SMTP_PASS is not set")
|
||||
}
|
||||
if c.From == "" {
|
||||
c.From = "Verae TimeStamp <" + c.User + ">"
|
||||
}
|
||||
if _, err := mail.ParseAddress(c.From); err != nil {
|
||||
return c, fmt.Errorf("SMTP_FROM is not a valid address: %v", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func envOr(k, d string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Message is a rendered email.
|
||||
type Message struct {
|
||||
To string
|
||||
Subject string
|
||||
Text string
|
||||
HTML string
|
||||
}
|
||||
|
||||
// Sender is what the install API needs (mockable).
|
||||
type Sender interface {
|
||||
Send(m Message) error
|
||||
}
|
||||
|
||||
// SMTPSender implements Sender.
|
||||
type SMTPSender struct{ Cfg Config }
|
||||
|
||||
func (s *SMTPSender) scrub(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
if s.Cfg.Pass != "" {
|
||||
msg = strings.ReplaceAll(msg, s.Cfg.Pass, "[redacted]")
|
||||
msg = strings.ReplaceAll(msg, base64.StdEncoding.EncodeToString([]byte("\x00"+s.Cfg.User+"\x00"+s.Cfg.Pass)), "[redacted]")
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
func isLocal(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1" }
|
||||
|
||||
// Send delivers m. STARTTLS (or implicit TLS on 465) is mandatory except for
|
||||
// AllowInsecureLocal test servers.
|
||||
func (s *SMTPSender) Send(m Message) error { return s.scrub(s.send(m)) }
|
||||
|
||||
func (s *SMTPSender) send(m Message) error {
|
||||
c := s.Cfg
|
||||
to, err := mail.ParseAddress(m.To)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad recipient: %v", err)
|
||||
}
|
||||
from, err := mail.ParseAddress(c.From)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad sender: %v", err)
|
||||
}
|
||||
if strings.ContainsAny(m.Subject, "\r\n") {
|
||||
return errors.New("bad subject")
|
||||
}
|
||||
raw, err := Build(from, to, m, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
|
||||
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12}
|
||||
timeout := c.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 20 * time.Second
|
||||
}
|
||||
var conn net.Conn
|
||||
if c.Port == 465 {
|
||||
conn, err = tls.DialWithDialer(&net.Dialer{Timeout: timeout}, "tcp", addr, tlsCfg)
|
||||
} else {
|
||||
conn, err = net.DialTimeout("tcp", addr, timeout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect %s: %v", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(2 * timeout))
|
||||
cl, err := smtp.NewClient(conn, c.Host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("smtp hello: %v", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
if err := cl.Hello("verae-mailer"); err != nil {
|
||||
return fmt.Errorf("ehlo: %v", err)
|
||||
}
|
||||
if c.Port != 465 {
|
||||
if ok, _ := cl.Extension("STARTTLS"); ok {
|
||||
if err := cl.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("starttls: %v", err)
|
||||
}
|
||||
} else if !(c.AllowInsecureLocal && isLocal(c.Host)) {
|
||||
return errors.New("server does not offer STARTTLS; refusing to send credentials in clear text")
|
||||
}
|
||||
}
|
||||
if ok, _ := cl.Extension("AUTH"); ok {
|
||||
if err := cl.Auth(smtp.PlainAuth("", c.User, c.Pass, c.Host)); err != nil {
|
||||
return fmt.Errorf("auth as %s failed: %v", c.User, err)
|
||||
}
|
||||
}
|
||||
if err := cl.Mail(from.Address); err != nil {
|
||||
return fmt.Errorf("mail from: %v", err)
|
||||
}
|
||||
if err := cl.Rcpt(to.Address); err != nil {
|
||||
return fmt.Errorf("rcpt: %v", err)
|
||||
}
|
||||
w, err := cl.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %v", err)
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return fmt.Errorf("write: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("data end: %v", err)
|
||||
}
|
||||
return cl.Quit()
|
||||
}
|
||||
|
||||
// Build renders a multipart/alternative RFC 5322 message.
|
||||
func Build(from, to *mail.Address, m Message, now time.Time) ([]byte, error) {
|
||||
bnd, err := randHex(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, _ := randHex(12)
|
||||
dom := "verae.local"
|
||||
if i := strings.LastIndex(from.Address, "@"); i >= 0 {
|
||||
dom = from.Address[i+1:]
|
||||
}
|
||||
var b bytes.Buffer
|
||||
h := func(k, v string) { fmt.Fprintf(&b, "%s: %s\r\n", k, v) }
|
||||
h("From", from.String())
|
||||
h("To", to.String())
|
||||
h("Subject", mimeHeader(m.Subject))
|
||||
h("Date", now.Format(time.RFC1123Z))
|
||||
h("Message-ID", "<"+id+"@"+dom+">")
|
||||
h("MIME-Version", "1.0")
|
||||
h("Auto-Submitted", "auto-generated")
|
||||
h("Content-Type", `multipart/alternative; boundary="`+bnd+`"`)
|
||||
b.WriteString("\r\n")
|
||||
part := func(ct, body string) {
|
||||
fmt.Fprintf(&b, "--%s\r\nContent-Type: %s; charset=UTF-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n", bnd, ct)
|
||||
qp := quotedprintable.NewWriter(&b)
|
||||
_, _ = qp.Write([]byte(strings.ReplaceAll(body, "\n", "\r\n")))
|
||||
_ = qp.Close()
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
part("text/plain", m.Text)
|
||||
part("text/html", m.HTML)
|
||||
fmt.Fprintf(&b, "--%s--\r\n", bnd)
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func mimeHeader(s string) string {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func randHex(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", buf), nil
|
||||
}
|
||||
|
||||
// NewCode returns a uniformly random 6-digit code (crypto/rand).
|
||||
func NewCode() (string, error) {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64()), nil
|
||||
}
|
||||
|
||||
// MaskEmail: george@example.com → g***@example.com (for logs).
|
||||
func MaskEmail(e string) string {
|
||||
i := strings.LastIndex(e, "@")
|
||||
if i <= 0 {
|
||||
return "***"
|
||||
}
|
||||
return e[:1] + "***" + e[i:]
|
||||
}
|
||||
196
internal/mailer/mailer_test.go
Normal file
196
internal/mailer/mailer_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeSMTP: minimal server without STARTTLS, records AUTH and DATA.
|
||||
type fakeSMTP struct {
|
||||
ln net.Listener
|
||||
mu sync.Mutex
|
||||
auth string
|
||||
data string
|
||||
rcpt string
|
||||
}
|
||||
|
||||
func startFake(t *testing.T) *fakeSMTP {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeSMTP{ln: ln}
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go f.serve(c)
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) serve(c net.Conn) {
|
||||
defer c.Close()
|
||||
r := bufio.NewReader(c)
|
||||
w := func(s string) { c.Write([]byte(s + "\r\n")) }
|
||||
w("220 fake")
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
up := strings.ToUpper(line)
|
||||
switch {
|
||||
case strings.HasPrefix(up, "EHLO"):
|
||||
w("250-fake")
|
||||
w("250 AUTH PLAIN")
|
||||
case strings.HasPrefix(up, "AUTH PLAIN"):
|
||||
f.mu.Lock()
|
||||
f.auth = strings.TrimSpace(line[len("AUTH PLAIN"):])
|
||||
f.mu.Unlock()
|
||||
w("235 ok")
|
||||
case strings.HasPrefix(up, "MAIL FROM"):
|
||||
w("250 ok")
|
||||
case strings.HasPrefix(up, "RCPT TO"):
|
||||
f.mu.Lock()
|
||||
f.rcpt = line
|
||||
f.mu.Unlock()
|
||||
w("250 ok")
|
||||
case up == "DATA":
|
||||
w("354 go")
|
||||
var b strings.Builder
|
||||
for {
|
||||
l, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if l == ".\r\n" {
|
||||
break
|
||||
}
|
||||
b.WriteString(l)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.data = b.String()
|
||||
f.mu.Unlock()
|
||||
w("250 queued")
|
||||
case up == "QUIT":
|
||||
w("221 bye")
|
||||
return
|
||||
default:
|
||||
w("250 ok")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSMTP) port() int { return f.ln.Addr().(*net.TCPAddr).Port }
|
||||
|
||||
func TestSendViaFakeSMTP(t *testing.T) {
|
||||
f := startFake(t)
|
||||
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "veraetimestamp@gmail.com", Pass: "s3cret-app-pw", From: "Verae TimeStamp <veraetimestamp@gmail.com>", AllowInsecureLocal: true, Timeout: 5 * time.Second}}
|
||||
m := CodeEmail("george@example.com", "George", "123456", 15, "")
|
||||
if err := s.Send(m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
dec, _ := base64.StdEncoding.DecodeString(f.auth)
|
||||
if string(dec) != "\x00veraetimestamp@gmail.com\x00s3cret-app-pw" {
|
||||
t.Fatalf("auth %q", dec)
|
||||
}
|
||||
for _, want := range []string{"multipart/alternative", "text/plain", "text/html", "Subject: Your Verae verification code: 123 456", "To: <george@example.com>"} {
|
||||
if !strings.Contains(f.data, want) {
|
||||
t.Errorf("message missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(f.rcpt, "george@example.com") {
|
||||
t.Errorf("rcpt %q", f.rcpt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusesPlaintextWithoutOptIn(t *testing.T) {
|
||||
f := startFake(t)
|
||||
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: f.port(), User: "u@x.com", Pass: "pw-should-not-leak", From: "u@x.com", Timeout: 5 * time.Second}}
|
||||
err := s.Send(CodeEmail("a@b.com", "", "000111", 15, ""))
|
||||
if err == nil || !strings.Contains(err.Error(), "STARTTLS") {
|
||||
t.Fatalf("want STARTTLS refusal, got %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "pw-should-not-leak") {
|
||||
t.Fatal("password leaked into error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigNeverPrintsPassword(t *testing.T) {
|
||||
t.Setenv("SMTP_USER", "veraetimestamp@gmail.com")
|
||||
t.Setenv("SMTP_PASS", "abcd efgh ijkl mnop")
|
||||
t.Setenv("SMTP_HOST", "")
|
||||
t.Setenv("SMTP_PORT", "")
|
||||
t.Setenv("SMTP_FROM", "")
|
||||
c, err := FromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Host != "smtp.gmail.com" || c.Port != 587 || c.From != "Verae TimeStamp <veraetimestamp@gmail.com>" {
|
||||
t.Fatalf("defaults: %v", c)
|
||||
}
|
||||
if strings.Contains(c.String(), "abcd") {
|
||||
t.Fatal("String() leaked password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsHeaderInjection(t *testing.T) {
|
||||
s := &SMTPSender{Cfg: Config{Host: "127.0.0.1", Port: 1, User: "u@x.com", Pass: "p", From: "u@x.com"}}
|
||||
if err := s.Send(Message{To: "a@b.com\r\nBcc: evil@x.com", Subject: "x"}); err == nil {
|
||||
t.Fatal("expected bad recipient")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateEscapesAndCode(t *testing.T) {
|
||||
m := CodeEmail("a@b.com", "<script>", "987654", 15, "")
|
||||
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "987654") || !strings.Contains(m.Text, "987 654") {
|
||||
t.Fatal("template")
|
||||
}
|
||||
c, err := NewCode()
|
||||
if err != nil || len(c) != 6 {
|
||||
t.Fatal(c, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
l := NewLimiter(Limits{Cooldown: time.Minute, PerEmailHour: 3, PerIPHour: 4, GlobalMinute: 100, GlobalDay: 1000})
|
||||
l.Now = func() time.Time { return now }
|
||||
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "cooldown") {
|
||||
t.Fatalf("want cooldown, got %v", err)
|
||||
}
|
||||
now = now.Add(61 * time.Second)
|
||||
_ = l.Allow("a@x", "1.1.1.1")
|
||||
now = now.Add(61 * time.Second)
|
||||
_ = l.Allow("a@x", "1.1.1.1")
|
||||
now = now.Add(61 * time.Second)
|
||||
if err := l.Allow("a@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "too many codes") {
|
||||
t.Fatalf("want per-email cap, got %v", err)
|
||||
}
|
||||
if err := l.Allow("b@x", "1.1.1.1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Allow("c@x", "1.1.1.1"); err == nil || !strings.Contains(err.Error(), "network") {
|
||||
t.Fatalf("want per-IP cap, got %v", err)
|
||||
}
|
||||
now = now.Add(time.Hour)
|
||||
if err := l.Allow("a@x", "1.1.1.1"); err != nil {
|
||||
t.Fatalf("window should reset: %v", err)
|
||||
}
|
||||
}
|
||||
96
internal/mailer/ratelimit.go
Normal file
96
internal/mailer/ratelimit.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limits for verification emails (defaults match docs/installer-registration-api.md).
|
||||
type Limits struct {
|
||||
Cooldown time.Duration // min gap between emails to one address (resend)
|
||||
PerEmailHour int // emails per address per hour
|
||||
PerIPHour int // emails triggered per client IP per hour
|
||||
GlobalMinute int // all emails per minute (protects the Gmail account: ~500/day cap)
|
||||
GlobalDay int
|
||||
}
|
||||
|
||||
// DefaultLimits keeps well under Gmail's sending limits.
|
||||
var DefaultLimits = Limits{Cooldown: 60 * time.Second, PerEmailHour: 5, PerIPHour: 10, GlobalMinute: 20, GlobalDay: 400}
|
||||
|
||||
// Limiter is an in-memory sliding-window limiter (single instance; use a shared
|
||||
// store such as Redis when running several API replicas).
|
||||
type Limiter struct {
|
||||
L Limits
|
||||
Now func() time.Time
|
||||
mu sync.Mutex
|
||||
ev map[string][]time.Time
|
||||
}
|
||||
|
||||
func NewLimiter(l Limits) *Limiter {
|
||||
return &Limiter{L: l, Now: time.Now, ev: map[string][]time.Time{}}
|
||||
}
|
||||
|
||||
// LimitError carries a retry hint.
|
||||
type LimitError struct {
|
||||
Reason string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string {
|
||||
return fmt.Sprintf("rate limited (%s); retry in %ds", e.Reason, int(e.RetryAfter.Seconds()+0.999))
|
||||
}
|
||||
|
||||
func (l *Limiter) prune(k string, win time.Duration, now time.Time) []time.Time {
|
||||
v := l.ev[k]
|
||||
i := 0
|
||||
for i < len(v) && now.Sub(v[i]) >= win {
|
||||
i++
|
||||
}
|
||||
v = v[i:]
|
||||
l.ev[k] = v
|
||||
return v
|
||||
}
|
||||
|
||||
// Allow checks every limit for (email, ip) and records the event only if all pass.
|
||||
func (l *Limiter) Allow(email, ip string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.Now()
|
||||
type rule struct {
|
||||
key string
|
||||
win time.Duration
|
||||
max int
|
||||
reason string
|
||||
}
|
||||
rules := []rule{
|
||||
{"e:" + email, l.L.Cooldown, 1, "resend cooldown"},
|
||||
{"e:" + email, time.Hour, l.L.PerEmailHour, "too many codes for this email"},
|
||||
{"i:" + ip, time.Hour, l.L.PerIPHour, "too many requests from this network"},
|
||||
{"g", time.Minute, l.L.GlobalMinute, "service busy"},
|
||||
{"g", 24 * time.Hour, l.L.GlobalDay, "daily email limit reached"},
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r.max <= 0 || r.win <= 0 {
|
||||
continue
|
||||
}
|
||||
v := l.prune(r.key, 24*time.Hour, now)
|
||||
n := 0
|
||||
var oldest time.Time
|
||||
for _, t := range v {
|
||||
if now.Sub(t) < r.win {
|
||||
if n == 0 {
|
||||
oldest = t
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n >= r.max {
|
||||
return &LimitError{Reason: r.reason, RetryAfter: r.win - now.Sub(oldest)}
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"e:" + email, "i:" + ip, "g"} {
|
||||
l.ev[k] = append(l.ev[k], now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
92
internal/mailer/template.go
Normal file
92
internal/mailer/template.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package mailer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CodeEmail renders the verification-code email (plain text + HTML).
|
||||
func CodeEmail(to, name, code string, ttlMinutes int, client string) Message {
|
||||
greet := "Hello"
|
||||
if n := strings.TrimSpace(name); n != "" {
|
||||
greet = "Hello " + n
|
||||
}
|
||||
if client == "" {
|
||||
client = "the Verae installer"
|
||||
}
|
||||
spaced := code
|
||||
if len(code) == 6 {
|
||||
spaced = code[:3] + " " + code[3:]
|
||||
}
|
||||
text := fmt.Sprintf(`%s,
|
||||
|
||||
Your Verae verification code is:
|
||||
|
||||
%s
|
||||
|
||||
Enter it in %s to finish creating your Verae account.
|
||||
The code expires in %d minutes and can be used once.
|
||||
|
||||
If you did not try to create a Verae account, you can ignore this email;
|
||||
no account is created without this code.
|
||||
|
||||
Verae TimeStamp - https://customer.verae.ai/portal/
|
||||
This is an automated message; replies are not monitored.
|
||||
`, greet, spaced, client, ttlMinutes)
|
||||
|
||||
e := html.EscapeString
|
||||
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Your Verae verification code</title></head>
|
||||
<body style="margin:0;padding:0;background:#f4f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;">
|
||||
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:#f4f5f7;padding:32px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;width:100%%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
|
||||
<tr><td style="background:#0b3d91;padding:20px 28px;color:#ffffff;font-size:20px;font-weight:600;letter-spacing:.3px;">Verae TimeStamp</td></tr>
|
||||
<tr><td style="padding:28px;">
|
||||
<p style="margin:0 0 16px;font-size:16px;">%s,</p>
|
||||
<p style="margin:0 0 20px;font-size:15px;line-height:1.5;">Enter this code in %s to finish creating your Verae account:</p>
|
||||
<div style="margin:0 0 20px;padding:18px 0;text-align:center;background:#eef3fb;border-radius:8px;font-family:Consolas,Menlo,monospace;font-size:34px;font-weight:700;letter-spacing:8px;color:#0b3d91;">%s</div>
|
||||
<p style="margin:0 0 8px;font-size:14px;color:#52606d;">The code expires in <b>%d minutes</b> and can be used once.</p>
|
||||
<p style="margin:0;font-size:14px;color:#52606d;">If you did not try to create a Verae account, ignore this email — no account is created without this code.</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:16px 28px;border-top:1px solid #e4e7eb;font-size:12px;color:#7b8794;">
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;text-decoration:none;">customer.verae.ai</a> · Automated message, replies are not monitored.
|
||||
</td></tr></table>
|
||||
</td></tr></table>
|
||||
</body></html>
|
||||
`, e(greet), e(client), e(code), ttlMinutes)
|
||||
|
||||
return Message{
|
||||
To: to,
|
||||
Subject: "Your Verae verification code: " + spaced,
|
||||
Text: text,
|
||||
HTML: htmlBody,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountExistsEmail is sent instead of a code when someone registers an
|
||||
// address that already has an account (so /install/register reveals nothing).
|
||||
func AccountExistsEmail(to string) Message {
|
||||
text := `Hello,
|
||||
|
||||
Someone (hopefully you) tried to create a new Verae account with this email
|
||||
address, but you already have one.
|
||||
|
||||
To use the Verae printer, choose "Sign in to my Verae account" in the installer.
|
||||
Forgot your password? Reset it at https://customer.verae.ai/portal/
|
||||
|
||||
If this wasn't you, you can ignore this email.
|
||||
|
||||
Verae TimeStamp - automated message; replies are not monitored.
|
||||
`
|
||||
htmlBody := `<!DOCTYPE html><html><body style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1f2933;background:#f4f5f7;padding:32px;">
|
||||
<div style="max-width:480px;margin:auto;background:#fff;border-radius:12px;padding:28px;">
|
||||
<h2 style="margin-top:0;color:#0b3d91;">You already have a Verae account</h2>
|
||||
<p>Someone (hopefully you) tried to create a new Verae account with this email address.</p>
|
||||
<p>In the installer choose <b>Sign in to my Verae account</b>. Forgot your password? Reset it at
|
||||
<a href="https://customer.verae.ai/portal/" style="color:#0b3d91;">customer.verae.ai</a>.</p>
|
||||
<p style="color:#52606d;font-size:14px;">If this wasn't you, ignore this email.</p></div></body></html>`
|
||||
return Message{To: to, Subject: "You already have a Verae account", Text: text, HTML: htmlBody}
|
||||
}
|
||||
Loading…
Reference in a new issue