feat(API): user invitations and emails

This commit is contained in:
fsylvain committed 2026-09-29 07:15:17 +02:00
1 parent 7aae704d0a
commit acdef18b37
17 files changed
+1687 -7

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
package invite
import (
"errors"
"time"
)
var ErrEmailHasAccount = errors.New("email already has an account")
var ErrPendingInvite = errors.New("pending invite already exists")
var ErrInviteNotFound = errors.New("invite not found")
var ErrInviteEmailNotSent = errors.New("invite email not sent")
const Lifetime = 3 * 24 * time.Hour
type Status string
const (
StatusPending Status = "pending"
StatusExpired Status = "expired"
StatusAccepted Status = "accepted"
)
type Invite struct {
ID uint32 `json:"id"`
Email string `json:"email"`
HashedCode string `json:"hashed_code"`
ExpiresAt time.Time `json:"expires_at"`
InvitedBy uint32 `json:"invited_by"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedAt time.Time `json:"created_at"`
}
func (i Invite) StatusAt(now time.Time) Status {
if i.AcceptedAt != nil {
return StatusAccepted
}
if i.ExpiresAt.After(now) {
return StatusPending
}
return StatusExpired
}
@@ -0,0 +1,7 @@
package invite
type Repository interface {
Create(invite Invite) (Invite, error)
GetAll() []Invite
Delete(id uint32) error
}
@@ -24,14 +24,25 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
const defaultSubject = "RenewCMS | Action required"
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := defaultSubject
type subjectProvider interface {
Subject() string
}
func subjectFor(templateName string, data any) string {
if provider, ok := data.(subjectProvider); ok {
return provider.Subject()
}
switch templateName {
case "verification":
subject = "Verify your RenewCMS account"
return "Verify your RenewCMS account"
case "password-reset":
subject = "Reset your RenewCMS password"
return "Reset your RenewCMS password"
}
return defaultSubject
}
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
subject := subjectFor(templateName, data)
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
if err != nil {
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>You're invited to RenewCMS</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{.InvitedBy}} invited you to work on articles in RenewCMS. The invite expires in 3 days.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">You're invited to RenewCMS</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">{{.InvitedBy}} invited you to write and publish articles in RenewCMS at {{.Host}}. Accept to choose a username and password. You'll sign in with your own account.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/invite/accept?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Accept invite</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This invite was sent to {{.Email}}. It expires in 3 days and works once.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/invite/accept?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">Not expecting this? Ignore this email and no account will be created. If the invite has expired, ask {{.InvitedBy}} to send a new one.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,30 @@
package mappers
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/models"
)
func InviteToDomain(m models.Invite) invite.Invite {
return invite.Invite{
ID: m.ID,
Email: m.Email,
HashedCode: m.HashedCode,
ExpiresAt: m.ExpiresAt,
InvitedBy: m.InvitedBy,
AcceptedAt: m.AcceptedAt,
CreatedAt: m.CreatedAt,
}
}
func InviteToModel(i invite.Invite) models.Invite {
return models.Invite{
ID: i.ID,
Email: i.Email,
HashedCode: i.HashedCode,
ExpiresAt: i.ExpiresAt,
InvitedBy: i.InvitedBy,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
}
@@ -0,0 +1,13 @@
package models
import "time"
type Invite struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Email string `gorm:"index;not null"`
HashedCode string `gorm:"not null"`
ExpiresAt time.Time
InvitedBy uint32 `gorm:"not null"`
AcceptedAt *time.Time
CreatedAt time.Time `gorm:"autoCreateTime"`
}
@@ -0,0 +1,46 @@
package persistence
import (
domainInvite "RenewCMS/internal/domain/invite"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type InviteRepository struct {
db *gorm.DB
}
func NewInviteRepository(db *gorm.DB) *InviteRepository {
return &InviteRepository{db}
}
func (i *InviteRepository) Create(invite domainInvite.Invite) (domainInvite.Invite, error) {
model := mappers.InviteToModel(invite)
model.ID = 0
if err := i.db.Create(&model).Error; err != nil {
return domainInvite.Invite{}, err
}
return mappers.InviteToDomain(model), nil
}
func (i *InviteRepository) GetAll() []domainInvite.Invite {
var invites []entity.Invite
i.db.Model(&entity.Invite{}).Find(&invites)
var domainInvites []domainInvite.Invite
for _, localInvite := range invites {
domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite))
}
return domainInvites
}
func (i *InviteRepository) Delete(id uint32) error {
result := i.db.Delete(&entity.Invite{}, id)
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
}
var _ domainInvite.Repository = &InviteRepository{}
@@ -0,0 +1,128 @@
package useCases
import (
"RenewCMS/internal/domain/hash"
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/mail"
"RenewCMS/internal/domain/user"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
const inviteTemplateName = "invitation"
type CreateInviteUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
hasher hash.Hasher
mailRepository mail.Repository
publicURL string
}
type CreateInviteCommand struct {
Email string
InvitedBy uint32
}
type MailInviteData struct {
Host string
VerificationCode string
Email string
InvitedBy string
}
func (d MailInviteData) Subject() string {
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
return inviter + " invited you to RenewCMS"
}
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL}
}
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
email := strings.ToLower(command.Email)
now := time.Now()
inviter, err := g.userRepository.Get(command.InvitedBy)
if err != nil {
return invite.Invite{}, err
}
if g.emailHasAccount(email) {
return invite.Invite{}, invite.ErrEmailHasAccount
}
if err := g.discardOpenInvite(email, now); err != nil {
return invite.Invite{}, err
}
rawCode := uuid.NewString()
hashedCode, err := g.hasher.Hash(rawCode)
if err != nil {
return invite.Invite{}, err
}
createdInvite, err := g.inviteRepository.Create(invite.Invite{
Email: email,
HashedCode: hashedCode,
ExpiresAt: now.Add(invite.Lifetime),
InvitedBy: command.InvitedBy,
CreatedAt: now,
})
if err != nil {
return invite.Invite{}, err
}
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
Host: g.publicURL,
VerificationCode: rawCode,
Email: email,
InvitedBy: inviterName(inviter),
}); err != nil {
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
return invite.Invite{}, errors.Join(sendErr, deleteErr)
}
return invite.Invite{}, sendErr
}
return createdInvite, nil
}
func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
for _, existingUser := range g.userRepository.GetAll() {
if strings.EqualFold(existingUser.Email, email) {
return true
}
}
return false
}
func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error {
for _, existing := range g.inviteRepository.GetAll() {
if existing.Email != email {
continue
}
switch existing.StatusAt(now) {
case invite.StatusPending:
return invite.ErrPendingInvite
case invite.StatusExpired:
if err := g.inviteRepository.Delete(existing.ID); err != nil {
return err
}
}
}
return nil
}
func inviterName(inviter user.User) string {
if inviter.DisplayName != "" {
return inviter.DisplayName
}
return inviter.Username
}
@@ -0,0 +1,68 @@
package useCases
import (
"RenewCMS/internal/domain/invite"
"RenewCMS/internal/domain/user"
"sort"
"time"
)
const (
EntryKindUser = "user"
EntryKindInvite = "invite"
)
type ListUsersAndInvitesUseCase struct {
userRepository user.Repository
inviteRepository invite.Repository
}
type DirectoryEntry struct {
ID uint32
Kind string
Username string
DisplayName string
Email string
Status string
CreatedAt time.Time
}
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase {
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository}
}
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
now := time.Now()
entries := []DirectoryEntry{}
for _, existingUser := range g.userRepository.GetAll() {
entries = append(entries, DirectoryEntry{
ID: existingUser.ID,
Kind: EntryKindUser,
Username: existingUser.Username,
DisplayName: existingUser.DisplayName,
Status: string(invite.StatusAccepted),
CreatedAt: existingUser.CreatedAt,
})
}
for _, existingInvite := range g.inviteRepository.GetAll() {
status := existingInvite.StatusAt(now)
if status == invite.StatusAccepted {
continue
}
entries = append(entries, DirectoryEntry{
ID: existingInvite.ID,
Kind: EntryKindInvite,
Email: existingInvite.Email,
Status: string(status),
CreatedAt: existingInvite.CreatedAt,
})
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].CreatedAt.After(entries[j].CreatedAt)
})
return entries
}