auto mail generation

This commit is contained in:
2026-09-29 16:53:21 +05:30
parent b18080d429
commit b46902f51b
27 changed files with 2765 additions and 80 deletions

152
utils/invitetoken.go Normal file
View File

@@ -0,0 +1,152 @@
package utils
import (
"crypto/hmac"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// The invitation a newly onboarded merchant receives by email.
//
// ── Why this is a token and not a userid ────────────────────────────────────
//
// `setpassword` used to take a bare `userid`, which was safe only because the
// caller had to reach it through a sign-in: `applogin` answers 409 with the
// userid for an account that has no password, and nothing else hands one out.
//
// Putting that userid in a link and mailing it changes the threat entirely.
// Userids are sequential, so a link is a guessable capability: walk low numbers
// and claim any merchant that has been onboarded and not yet set up. The
// attacker would own a real business's admin account — the empty-password check
// does not help, because an un-set-up account is exactly what they are hunting.
//
// So the invitation carries a signature instead. The userid is read out of the
// payload the server signed, never out of the request, which makes a forged or
// edited link fail before anything is looked up.
//
// ── Why the same secret ─────────────────────────────────────────────────────
//
// One signing key for the deployment, one place it can be missing, one error
// when it is — the same argument `MintWebToken` makes for sharing with the POS
// token. The prefix is what keeps the three kinds apart, and it is checked
// before the signature so an invitation can never be presented as a session.
// InviteClaims is who an invitation is for.
//
// Deliberately thin. A session carries a role, a branch and a config because
// requests are authorised against them; an invitation authorises exactly one
// act — setting a first password — and the account it names already holds
// everything else. Claims it does not need are claims that cannot be wrong.
type InviteClaims struct {
Userid int `json:"uid"`
// Carried for the audit line, not for the decision. `SetInitialPassword`
// re-derives everything it enforces from the account itself.
Tenantid int `json:"tid,omitempty"`
Issuedat int64 `json:"iat"`
Expiresat int64 `json:"exp"`
}
// InviteTokenTTL is how long an invitation stays usable.
//
// Seven days: long enough to survive a weekend, a holiday and an email that
// went to spam, short enough that a forwarded invitation found in a mailbox
// months later is no longer a way into the account. Merchants who miss it get
// a fresh one — a resend is cheap and an eternal link is not.
const InviteTokenTTL = 7 * 24 * time.Hour
// inviteTokenPrefix keeps an invitation from being mistaken for a session.
//
// Without it the two are the same shape signed with the same key, so an
// invitation would verify as a console session — and it names a userid with no
// role, no tenant check and a seven-day life. That is a far weaker credential
// than a session, and it must not be usable as one.
const inviteTokenPrefix = "i1."
// MintInviteToken issues the link a new merchant is emailed.
func MintInviteToken(claims InviteClaims, now time.Time) (string, time.Time, error) {
secret, err := posTokenSecret()
if err != nil {
return "", time.Time{}, err
}
if claims.Userid <= 0 {
return "", time.Time{}, fmt.Errorf("an invitation must name a user")
}
expires := now.Add(InviteTokenTTL)
claims.Issuedat = now.Unix()
claims.Expiresat = expires.Unix()
payload, err := json.Marshal(claims)
if err != nil {
return "", time.Time{}, err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
return inviteTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil
}
// ParseInviteToken verifies an invitation and returns who it is for.
//
// Same order as the session parser, for the same reason: nothing in the payload
// is trusted — not the expiry, not the user — until the signature has been
// checked. Reading `exp` from an unverified payload is taking the caller's word
// for when their own link runs out.
// Every refusal below is written as a sentence, capital letter and full stop,
// against Go's convention for error strings — because these are not read by a
// developer. `SetPassword` puts them straight into the `message` a merchant sees
// on `/set-password`, and they are the only explanation that screen has. A
// lowercase fragment in a red banner reads as something that leaked out of the
// machine rather than something anybody meant to say.
//
// They also all say what to do next, because every one of them is a dead end
// otherwise: the person is holding a link that does not work and has no password
// to sign in with instead.
func ParseInviteToken(token string, now time.Time) (InviteClaims, error) {
secret, err := posTokenSecret()
if err != nil {
return InviteClaims{}, err
}
raw := strings.TrimSpace(token)
after, found := strings.CutPrefix(raw, inviteTokenPrefix)
if !found {
return InviteClaims{}, fmt.Errorf("This is not an invitation link.")
}
encoded, signature, found := strings.Cut(after, ".")
if !found || encoded == "" || signature == "" {
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
}
// Constant time, so the right signature cannot be learned a byte at a time
// from how long the comparison took.
if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
return InviteClaims{}, fmt.Errorf("This invitation link is not valid. Ask whoever set you up to send another.")
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
}
var claims InviteClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.")
}
if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
// Says what to do about it. An expired invitation is the one failure
// here somebody can resolve themselves, and "invalid" would send them
// to support instead of to whoever onboarded them.
return InviteClaims{}, fmt.Errorf("This invitation has expired. Ask whoever set you up to send another.")
}
if claims.Userid <= 0 {
return InviteClaims{}, fmt.Errorf("This invitation names no account. Ask whoever set you up to send another.")
}
return claims, nil
}

191
utils/invitetoken_test.go Normal file
View File

@@ -0,0 +1,191 @@
package utils
import (
"strings"
"testing"
"time"
)
/*
The invitation a newly onboarded merchant is emailed.
`setpassword` took a bare userid, which was safe only because the caller had to
reach it through a sign-in — `applogin` answers 409 with the userid for an
account that has no password, and nothing else hands one out.
Mailing that userid as a link changes the threat completely: userids are
sequential, so the link becomes a guessable capability. Walk low numbers and
claim any merchant onboarded but not yet set up, and you own a real business's
admin account. The empty-password check is no defence — an un-set-up account is
precisely what such an attacker is looking for.
So these tests are mostly about what the token REFUSES.
*/
const inviteSecret = "an-invitation-signing-secret-long-enough"
func inviteEnv(t *testing.T) {
t.Helper()
t.Setenv("POS_TOKEN_SECRET", inviteSecret)
}
func TestAnInvitationNamesTheAccountItWasIssuedFor(t *testing.T) {
inviteEnv(t)
now := time.Now()
token, expires, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
claims, err := ParseInviteToken(token, now)
if err != nil {
t.Fatalf("parsing: %v", err)
}
if claims.Userid != 904 || claims.Tenantid != 1147 {
t.Fatalf("claims came back as %+v", claims)
}
if expires.Sub(now) != InviteTokenTTL {
t.Fatalf("expiry is %v, want %v", expires.Sub(now), InviteTokenTTL)
}
}
func TestAnEditedInvitationIsRefused(t *testing.T) {
// The whole point. If the payload could be edited, the link would be a
// userid in a longer coat and every account would be one base64 edit away.
inviteEnv(t)
now := time.Now()
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
// Re-sign nothing; just change the payload, which is what an attacker who
// decoded the link and wanted a different userid would do.
forged, _, err := MintInviteToken(InviteClaims{Userid: 905}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
parts := strings.Split(token, ".")
other := strings.Split(forged, ".")
swapped := parts[0] + "." + other[1] + "." + parts[2]
if _, err := ParseInviteToken(swapped, now); err == nil {
t.Fatal("a payload swapped under an old signature was accepted")
}
}
func TestAnInvitationSignedWithAnotherSecretIsRefused(t *testing.T) {
now := time.Now()
t.Setenv("POS_TOKEN_SECRET", "one-secret-that-is-long-enough-here")
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
t.Setenv("POS_TOKEN_SECRET", "a-different-secret-also-long-enough")
if _, err := ParseInviteToken(token, now); err == nil {
t.Fatal("an invitation from another deployment was accepted")
}
}
func TestAnExpiredInvitationSaysWhatToDo(t *testing.T) {
// The one failure here somebody can resolve themselves. "Not valid" would
// send them to support; naming a resend sends them to whoever onboarded
// them, which is where the fix actually is.
inviteEnv(t)
now := time.Now()
token, _, err := MintInviteToken(InviteClaims{Userid: 904}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
_, err = ParseInviteToken(token, now.Add(InviteTokenTTL+time.Second))
if err == nil {
t.Fatal("an expired invitation was accepted")
}
// An expired invitation is the one failure here somebody can resolve
// themselves, so it has to say how. "Invalid" would send them to support
// instead of to whoever onboarded them.
if !strings.Contains(err.Error(), "send another") {
t.Fatalf("the refusal does not say what to do: %v", err)
}
// Read by a merchant on `/set-password`, not by a developer in a log. A
// lowercase fragment in a red banner reads as something that leaked out.
if !strings.HasPrefix(err.Error(), "This") || !strings.HasSuffix(err.Error(), ".") {
t.Errorf("the refusal is not written as a sentence: %q", err)
}
}
func TestAnInvitationIsNotASession(t *testing.T) {
// They are the same shape signed with the same key. Without the prefix
// check an invitation would verify as a console session — and it names a
// userid with no role, no tenant check and a seven-day life, which is a far
// weaker credential than a session and must never be usable as one.
inviteEnv(t)
now := time.Now()
invite, _, err := MintInviteToken(InviteClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseWebToken(invite, now); err == nil {
t.Fatal("an invitation was accepted as a console session")
}
}
func TestASessionIsNotAnInvitation(t *testing.T) {
// The other direction, which matters less but costs nothing to close: a
// stolen session should not double as a password-reset link.
inviteEnv(t)
now := time.Now()
session, _, err := MintWebToken(WebClaims{Userid: 904, Tenantid: 1147}, now)
if err != nil {
t.Fatalf("minting: %v", err)
}
if _, err := ParseInviteToken(session, now); err == nil {
t.Fatal("a console session was accepted as an invitation")
}
}
func TestRubbishIsRefusedWithoutPanicking(t *testing.T) {
inviteEnv(t)
now := time.Now()
for _, bad := range []string{
"", " ", "i1.", "i1..", "i1.onlyonepart", "not-a-token",
"i1.!!!not-base64!!!.signature", "w1.something.else",
} {
if _, err := ParseInviteToken(bad, now); err == nil {
t.Fatalf("%q was accepted as an invitation", bad)
}
}
}
func TestAnInvitationMustNameSomebody(t *testing.T) {
// A token naming nobody authorises nothing, and must not be mistaken for
// one authorising everything — the same rule the session parser applies.
inviteEnv(t)
if _, _, err := MintInviteToken(InviteClaims{Userid: 0}, time.Now()); err == nil {
t.Fatal("an invitation was minted for user 0")
}
}
func TestNoSigningSecretMeansNoInvitations(t *testing.T) {
// Rather than issuing something unverifiable. A deployment that cannot sign
// cannot invite, and saying so at the point of minting is better than an
// email whose link never works.
t.Setenv("POS_TOKEN_SECRET", "")
t.Setenv("JWT_SECRET_KEY", "")
if _, _, err := MintInviteToken(InviteClaims{Userid: 904}, time.Now()); err == nil {
t.Fatal("an invitation was minted with no signing secret")
}
}

154
utils/mail.go Normal file
View File

@@ -0,0 +1,154 @@
package utils
import (
"crypto/tls"
"fmt"
"net/mail"
"net/smtp"
"strings"
"time"
"nearle/config"
)
// Sending mail.
//
// ── Why the standard library and not a client ───────────────────────────────
//
// `net/smtp` is enough for what this sends: a handful of invitations a day, one
// at a time, to addresses a person typed. Every transactional provider speaks
// SMTP — SES, SendGrid, Resend, a company relay — so the choice of provider is
// a host and a password rather than a dependency and a rewrite. Adding an SDK
// would buy templating and analytics that nothing here wants yet, in exchange
// for a supply chain.
//
// ── Nil is a configuration, not a failure ───────────────────────────────────
//
// `NewMailer` returns nil when no host is set, matching `NewChat` and
// `NewEmbedder`. A deployment without mail still boots, still onboards tenants,
// and records the invitation as unsent. The alternative — refusing to start —
// would make mail a hard dependency of creating a merchant, which it is not.
// Mailer sends one message.
//
// One method, on purpose. Everything this server sends is a short transactional
// note to one recipient; a richer interface would be describing a mail product
// nobody asked for.
type Mailer interface {
Send(to, subject, body string) error
}
// mailTimeout bounds a send.
//
// Onboarding waits on this, so it cannot be generous. A relay that has not
// answered in ten seconds is not going to, and the invitation is better
// recorded as unsent — and resent — than holding a tenant creation open.
const mailTimeout = 10 * time.Second
type smtpMailer struct {
cfg config.MailConfig
}
// NewMailer builds a sender, or nil when none is configured.
func NewMailer(cfg config.MailConfig) (Mailer, error) {
if !cfg.Enabled() {
return nil, nil
}
if _, err := mail.ParseAddress(cfg.FromAddress); err != nil {
// Caught here rather than at the first send, because a malformed
// sender fails every message and should stop the deployment being
// described as able to send.
return nil, fmt.Errorf("MAIL_FROM is not a valid address: %w", err)
}
return &smtpMailer{cfg: cfg}, nil
}
func (m *smtpMailer) Send(to, subject, body string) error {
recipient, err := mail.ParseAddress(strings.TrimSpace(to))
if err != nil {
// A merchant's primary email is typed by whoever onboarded them, so a
// typo here is ordinary. Named clearly, because the fix is to correct
// the tenant's record and resend.
return fmt.Errorf("%q is not a valid email address", to)
}
from := mail.Address{Name: m.cfg.FromName, Address: m.cfg.FromAddress}
message := buildMessage(from, *recipient, subject, body)
client, err := m.dial()
if err != nil {
return err
}
defer client.Close()
if m.cfg.Username != "" {
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("the mail server refused our credentials: %w", err)
}
}
if err := client.Mail(m.cfg.FromAddress); err != nil {
return fmt.Errorf("the mail server refused the sender: %w", err)
}
if err := client.Rcpt(recipient.Address); err != nil {
return fmt.Errorf("the mail server refused %s: %w", recipient.Address, err)
}
writer, err := client.Data()
if err != nil {
return err
}
if _, err := writer.Write([]byte(message)); err != nil {
return err
}
if err := writer.Close(); err != nil {
return err
}
return client.Quit()
}
// dial opens a connection, upgrading to TLS where the server offers it.
//
// STARTTLS rather than implicit TLS, because 587 is the submission port every
// provider documents and it begins in the clear. The upgrade is attempted
// whenever the server advertises it and the connection is abandoned if it fails
// — an invitation is a credential, and sending one over plaintext to a server
// that offered encryption would be choosing not to use it.
func (m *smtpMailer) dial() (*smtp.Client, error) {
client, err := smtp.Dial(m.cfg.Address())
if err != nil {
return nil, fmt.Errorf("could not reach the mail server at %s: %w", m.cfg.Address(), err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil {
client.Close()
return nil, fmt.Errorf("the mail server offered TLS and then refused it: %w", err)
}
}
return client, nil
}
// buildMessage assembles the wire format.
//
// Headers then a blank line then the body, with CRLF line endings — SMTP is
// specified in terms of CRLF and some servers reject bare newlines, which
// presents as mail that works locally and vanishes in production.
func buildMessage(from, to mail.Address, subject, body string) string {
var b strings.Builder
b.WriteString("From: " + from.String() + "\r\n")
b.WriteString("To: " + to.String() + "\r\n")
// Folded and encoded by `mail.Address`'s rules for the addresses; the
// subject is plain ASCII by construction in this codebase, so it needs no
// MIME word encoding. If a subject ever carries a merchant's name, that
// changes and this needs `mime.QEncoding`.
b.WriteString("Subject: " + strings.ReplaceAll(subject, "\n", " ") + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(body, "\n", "\r\n"))
return b.String()
}