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 }