auto mail generation
This commit is contained in:
151
services/inviteService.go
Normal file
151
services/inviteService.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/config"
|
||||
"nearle/utils"
|
||||
)
|
||||
|
||||
// The invitation a newly onboarded merchant receives.
|
||||
//
|
||||
// ── Why onboarding does not fail when this does ─────────────────────────────
|
||||
//
|
||||
// `Invite` never returns an error to the onboarding path. A tenant that exists
|
||||
// and has not been emailed is recoverable — somebody presses resend — while a
|
||||
// tenant rolled back because a mail relay was slow is a business that was
|
||||
// onboarded, told it was onboarded, and is not in the system. The first is a
|
||||
// task; the second is a phone call nobody can explain.
|
||||
//
|
||||
// So a failure is logged loudly and reported as `false`, and the caller decides
|
||||
// what to tell the operator. The platform console shows "invitation not sent"
|
||||
// beside the tenant, which is the state somebody can act on.
|
||||
type InviteService interface {
|
||||
// Invite emails a first-password link. Reports whether it was sent, and
|
||||
// why not when it was not — a sentence for the operator, not an error.
|
||||
//
|
||||
// `businessName` may be empty: the name is then looked up from `tenantid`,
|
||||
// because most callers hold an account and a tenantid and nothing else about
|
||||
// the business. A caller that already has the name — onboarding, which was
|
||||
// handed it in the form — passes it and saves the query.
|
||||
Invite(userid, tenantid int, email, businessName string) (bool, string)
|
||||
}
|
||||
|
||||
// TenantNamer reads a business's name for the invitation's first line.
|
||||
//
|
||||
// A one-method interface rather than the whole tenant repository, because that
|
||||
// is all this needs and because it keeps `inviteService` testable without a
|
||||
// database. `repositories.TenantRepository` satisfies it.
|
||||
type TenantNamer interface {
|
||||
TenantNameByID(tenantID int) (string, error)
|
||||
}
|
||||
|
||||
type inviteService struct {
|
||||
mailer utils.Mailer
|
||||
cfg config.MailConfig
|
||||
// May be nil. The invitation then says "your business", which is worse copy
|
||||
// and a working link — never a reason not to send.
|
||||
names TenantNamer
|
||||
}
|
||||
|
||||
func NewInviteService(mailer utils.Mailer, cfg config.MailConfig, names TenantNamer) InviteService {
|
||||
return &inviteService{mailer: mailer, cfg: cfg, names: names}
|
||||
}
|
||||
|
||||
func (s *inviteService) Invite(userid, tenantid int, email, businessName string) (bool, string) {
|
||||
address := strings.TrimSpace(email)
|
||||
if address == "" {
|
||||
return false, "no email address on the account"
|
||||
}
|
||||
if s.mailer == nil {
|
||||
// Not a fault. A deployment with no mail configured still onboards; the
|
||||
// reason names the variable so it is fixable rather than mysterious.
|
||||
return false, s.cfg.Why()
|
||||
}
|
||||
|
||||
token, _, err := utils.MintInviteToken(
|
||||
utils.InviteClaims{Userid: userid, Tenantid: tenantid}, time.Now())
|
||||
if err != nil {
|
||||
// Only happens with no signing secret, which is already fatal at boot
|
||||
// in production — but an invitation with no token would be a link that
|
||||
// cannot work, and sending it would be worse than not sending.
|
||||
log.Printf("invite: could not sign an invitation for user %d: %v", userid, err)
|
||||
return false, "this server cannot sign an invitation"
|
||||
}
|
||||
|
||||
subject, body := inviteMessage(s.businessName(tenantid, businessName), s.cfg.InviteLink(token))
|
||||
|
||||
if err := s.mailer.Send(address, subject, body); err != nil {
|
||||
log.Printf("invite: could not email user %d at %s: %v", userid, address, err)
|
||||
return false, err.Error()
|
||||
}
|
||||
|
||||
log.Printf("invite: sent to user %d for tenant %d", userid, tenantid)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// businessName is the name for the mail's first line.
|
||||
//
|
||||
// Looked up only when the caller did not have one. A failure is logged and
|
||||
// swallowed: the alternative is refusing to send somebody their only way into
|
||||
// their account because a name could not be read, and "your business has been
|
||||
// set up on Nearle" is a perfectly usable sentence.
|
||||
func (s *inviteService) businessName(tenantid int, given string) string {
|
||||
if name := strings.TrimSpace(given); name != "" {
|
||||
return name
|
||||
}
|
||||
if s.names == nil || tenantid <= 0 {
|
||||
return ""
|
||||
}
|
||||
name, err := s.names.TenantNameByID(tenantid)
|
||||
if err != nil {
|
||||
log.Printf("invite: could not read tenant %d's name: %v", tenantid, err)
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// inviteMessage is what the merchant reads.
|
||||
//
|
||||
// ── Why it says so little ───────────────────────────────────────────────────
|
||||
//
|
||||
// This is the first thing a new merchant receives from us and the only way into
|
||||
// their account, so it has one job: make the link obvious and make it credible.
|
||||
// Every extra paragraph is somewhere for the link to hide, and a mail full of
|
||||
// features reads like marketing — which is the thing people delete.
|
||||
//
|
||||
// It states who it is for and what it does, gives the link on its own line, and
|
||||
// says how long it lasts. The expiry is there because an invitation found three
|
||||
// weeks later needs to explain itself rather than look broken.
|
||||
//
|
||||
// Plain text, not HTML. A password link that arrives as an image-heavy template
|
||||
// is the shape of a phishing mail, and plain text renders identically
|
||||
// everywhere.
|
||||
func inviteMessage(businessName, link string) (subject, body string) {
|
||||
name := strings.TrimSpace(businessName)
|
||||
if name == "" {
|
||||
name = "your business"
|
||||
}
|
||||
|
||||
subject = "Set your Nearle password"
|
||||
|
||||
body = fmt.Sprintf(`%s has been set up on Nearle.
|
||||
|
||||
To finish, choose a password for your account:
|
||||
|
||||
%s
|
||||
|
||||
This link is for you alone and works once. It expires in 7 days — if it has,
|
||||
ask whoever set you up to send another.
|
||||
|
||||
If you were not expecting this, you can ignore it. Nothing happens until
|
||||
somebody uses the link.
|
||||
|
||||
— Nearle
|
||||
`, name, link)
|
||||
|
||||
return subject, body
|
||||
}
|
||||
Reference in New Issue
Block a user