Add email system: mailer + 7 transactional templates
This commit is contained in:
parent
e3ec9bc277
commit
242e063855
|
|
@ -0,0 +1,205 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSendTemplate(t *testing.T) {
|
||||
// Create a mailer with templates loaded
|
||||
m := &Mailer{enabled: true}
|
||||
|
||||
// Find the email templates directory
|
||||
templateDir := "../portal/emails"
|
||||
if _, err := os.Stat(templateDir); os.IsNotExist(err) {
|
||||
templateDir = "portal/emails"
|
||||
if _, err := os.Stat(templateDir); os.IsNotExist(err) {
|
||||
t.Skipf("email templates directory not found, skipping test")
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(templateDir)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get absolute path: %v", err)
|
||||
}
|
||||
|
||||
if err := m.LoadTemplates(absPath); err != nil {
|
||||
t.Fatalf("failed to load templates: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tmplName string
|
||||
data any
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "invite",
|
||||
tmplName: "invite.html",
|
||||
data: InviteData{
|
||||
InviterName: "Sarah Mitchell",
|
||||
InviterOrg: "Goldman Sachs",
|
||||
ProjectName: "Project Phoenix",
|
||||
InviteURL: "https://app.muskepo.com/invite/abc123",
|
||||
RecipientName: "John Smith",
|
||||
ExpiresIn: "7 days",
|
||||
},
|
||||
expected: []string{"Sarah Mitchell", "Goldman Sachs", "Project Phoenix", "Accept Invitation", "DEALSPACE"},
|
||||
},
|
||||
{
|
||||
name: "tasks_assigned",
|
||||
tmplName: "tasks_assigned.html",
|
||||
data: TasksAssignedData{
|
||||
RecipientName: "John Smith",
|
||||
ProjectName: "Project Phoenix",
|
||||
Count: 3,
|
||||
Tasks: []TaskItem{
|
||||
{Title: "FIN-001: Audited financials", DueDate: "March 15", Priority: "high"},
|
||||
},
|
||||
TasksURL: "https://app.muskepo.com/app/tasks",
|
||||
},
|
||||
expected: []string{"John Smith", "Project Phoenix", "3 new tasks", "View My Tasks"},
|
||||
},
|
||||
{
|
||||
name: "answer_submitted",
|
||||
tmplName: "answer_submitted.html",
|
||||
data: AnswerSubmittedData{
|
||||
AnswererName: "John Smith",
|
||||
RequestTitle: "FIN-001: Audited financials",
|
||||
WorkstreamName: "Finance",
|
||||
ReviewURL: "https://app.muskepo.com/app/review/ans_xyz123",
|
||||
},
|
||||
expected: []string{"John Smith", "FIN-001: Audited financials", "Finance", "Review Answer"},
|
||||
},
|
||||
{
|
||||
name: "answer_approved",
|
||||
tmplName: "answer_approved.html",
|
||||
data: AnswerApprovedData{
|
||||
RequestTitle: "FIN-001: Audited financials",
|
||||
Published: true,
|
||||
},
|
||||
expected: []string{"Your answer was approved", "FIN-001: Audited financials", "Published to Data Room"},
|
||||
},
|
||||
{
|
||||
name: "answer_rejected",
|
||||
tmplName: "answer_rejected.html",
|
||||
data: AnswerRejectedData{
|
||||
RequestTitle: "FIN-001: Audited financials",
|
||||
Reason: "Need final audited version",
|
||||
RequestURL: "https://app.muskepo.com/app/tasks/req_001",
|
||||
},
|
||||
expected: []string{"Your answer needs revision", "FIN-001: Audited financials", "View Feedback"},
|
||||
},
|
||||
{
|
||||
name: "request_forwarded",
|
||||
tmplName: "request_forwarded.html",
|
||||
data: RequestForwardedData{
|
||||
SenderName: "John Smith",
|
||||
RequestTitle: "FIN-003: AR aging report",
|
||||
RequestURL: "https://app.muskepo.com/app/tasks/req_003",
|
||||
DueDate: "March 18, 2026",
|
||||
HasDueDate: true,
|
||||
},
|
||||
expected: []string{"John Smith", "FIN-003: AR aging report", "View Request", "March 18, 2026"},
|
||||
},
|
||||
{
|
||||
name: "welcome",
|
||||
tmplName: "welcome.html",
|
||||
data: WelcomeData{
|
||||
RecipientName: "John Smith",
|
||||
TasksURL: "https://app.muskepo.com/app/tasks",
|
||||
},
|
||||
expected: []string{"Welcome to Dealspace", "John Smith", "Your inbox is your home", "Ask Aria", "Go to My Tasks"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := m.RenderTemplate(tc.tmplName, tc.data)
|
||||
if err != nil {
|
||||
t.Fatalf("RenderTemplate(%s) failed: %v", tc.tmplName, err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
t.Errorf("RenderTemplate(%s) returned empty result", tc.tmplName)
|
||||
}
|
||||
|
||||
for _, expected := range tc.expected {
|
||||
if !strings.Contains(result, expected) {
|
||||
t.Errorf("RenderTemplate(%s) missing expected string: %q", tc.tmplName, expected)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "<!DOCTYPE html>") {
|
||||
t.Errorf("RenderTemplate(%s) missing DOCTYPE", tc.tmplName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailerDisabled(t *testing.T) {
|
||||
m := &Mailer{enabled: false}
|
||||
|
||||
err := m.Send("test@example.com", "Test Subject", "<html>test</html>")
|
||||
if err != nil {
|
||||
t.Errorf("Send on disabled mailer should return nil, got: %v", err)
|
||||
}
|
||||
|
||||
err = m.SendTemplate("test@example.com", "Test Subject", "invite.html", nil)
|
||||
if err != nil {
|
||||
t.Errorf("SendTemplate on disabled mailer should return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailerEnabled(t *testing.T) {
|
||||
enabled := &Mailer{enabled: true}
|
||||
disabled := &Mailer{enabled: false}
|
||||
|
||||
if !enabled.Enabled() {
|
||||
t.Error("enabled mailer should return true for Enabled()")
|
||||
}
|
||||
if disabled.Enabled() {
|
||||
t.Error("disabled mailer should return false for Enabled()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64Encode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"", ""},
|
||||
{"f", "Zg=="},
|
||||
{"fo", "Zm8="},
|
||||
{"foo", "Zm9v"},
|
||||
{"foobar", "Zm9vYmFy"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
result := base64Encode(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("base64Encode(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailFuncs(t *testing.T) {
|
||||
funcs := emailFuncs()
|
||||
|
||||
gt := funcs["gt"].(func(int, int) bool)
|
||||
if !gt(5, 3) {
|
||||
t.Error("gt(5, 3) should be true")
|
||||
}
|
||||
|
||||
sub := funcs["sub"].(func(int, int) int)
|
||||
if sub(10, 3) != 7 {
|
||||
t.Error("sub(10, 3) should be 7")
|
||||
}
|
||||
|
||||
truncate := funcs["truncate"].(func(string, int) string)
|
||||
if truncate("hello world", 5) != "hello..." {
|
||||
t.Error("truncate('hello world', 5) should be 'hello...'")
|
||||
}
|
||||
}
|
||||
141
lib/notify.go
141
lib/notify.go
|
|
@ -1,5 +1,10 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Broadcast logic — answer published → notify all linked requesters.
|
||||
// Uses the broadcasts table for idempotency.
|
||||
|
||||
|
|
@ -25,3 +30,139 @@ func NotifyChainComplete(db *DB, cfg *Config, entryID string) error {
|
|||
// TODO: implement chain completion notification
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Email Notification Functions ----
|
||||
|
||||
// NotifyInvite sends an invitation email to a new user.
|
||||
func NotifyInvite(m *Mailer, to, inviterName, inviterOrg, projectName, inviteURL string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("You've been invited to join %s on Dealspace", projectName)
|
||||
data := InviteData{
|
||||
InviterName: inviterName,
|
||||
InviterOrg: inviterOrg,
|
||||
ProjectName: projectName,
|
||||
InviteURL: inviteURL,
|
||||
ExpiresIn: "7 days",
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "invite.html", data)
|
||||
}
|
||||
|
||||
// NotifyTasksAssigned sends an email when new tasks are assigned to a user.
|
||||
func NotifyTasksAssigned(m *Mailer, to, projectName string, tasks []string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
count := len(tasks)
|
||||
var subject string
|
||||
if count == 1 {
|
||||
subject = fmt.Sprintf("You have 1 new task on %s", projectName)
|
||||
} else {
|
||||
subject = fmt.Sprintf("You have %d new tasks on %s", count, projectName)
|
||||
}
|
||||
|
||||
// Convert task strings to TaskItem (simplified — in real usage, you'd pass full task data)
|
||||
taskItems := make([]TaskItem, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
taskItems = append(taskItems, TaskItem{
|
||||
Title: t,
|
||||
})
|
||||
}
|
||||
|
||||
data := TasksAssignedData{
|
||||
ProjectName: projectName,
|
||||
Count: count,
|
||||
Tasks: taskItems,
|
||||
TasksURL: "https://app.muskepo.com/app/tasks",
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "tasks_assigned.html", data)
|
||||
}
|
||||
|
||||
// NotifyAnswerSubmitted notifies an IB member that a seller submitted an answer.
|
||||
func NotifyAnswerSubmitted(m *Mailer, to, answererName, requestTitle, wsName, reviewURL string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("%s submitted an answer for: %s", answererName, requestTitle)
|
||||
data := AnswerSubmittedData{
|
||||
AnswererName: answererName,
|
||||
RequestTitle: requestTitle,
|
||||
WorkstreamName: wsName,
|
||||
ReviewURL: reviewURL,
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "answer_submitted.html", data)
|
||||
}
|
||||
|
||||
// NotifyAnswerApproved notifies a seller that their answer was approved.
|
||||
func NotifyAnswerApproved(m *Mailer, to, requestTitle string, published bool) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := "Your answer was approved ✓"
|
||||
data := AnswerApprovedData{
|
||||
RequestTitle: requestTitle,
|
||||
Published: published,
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "answer_approved.html", data)
|
||||
}
|
||||
|
||||
// NotifyAnswerRejected notifies a seller that their answer was rejected.
|
||||
func NotifyAnswerRejected(m *Mailer, to, requestTitle, reason, requestURL string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := "Your answer needs revision"
|
||||
data := AnswerRejectedData{
|
||||
RequestTitle: requestTitle,
|
||||
Reason: reason,
|
||||
RequestURL: requestURL,
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "answer_rejected.html", data)
|
||||
}
|
||||
|
||||
// NotifyRequestForwarded notifies a user that a request was forwarded to them.
|
||||
func NotifyRequestForwarded(m *Mailer, to, senderName, requestTitle, requestURL string, dueAt *time.Time) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("%s forwarded a request to you: %s", senderName, requestTitle)
|
||||
data := RequestForwardedData{
|
||||
SenderName: senderName,
|
||||
RequestTitle: requestTitle,
|
||||
RequestURL: requestURL,
|
||||
HasDueDate: dueAt != nil,
|
||||
}
|
||||
|
||||
if dueAt != nil {
|
||||
data.DueDate = dueAt.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "request_forwarded.html", data)
|
||||
}
|
||||
|
||||
// NotifyWelcome sends a welcome email to a new user after accepting an invite.
|
||||
func NotifyWelcome(m *Mailer, to, name, tasksURL string) error {
|
||||
if !m.Enabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
subject := "Welcome to Dealspace"
|
||||
data := WelcomeData{
|
||||
RecipientName: name,
|
||||
TasksURL: tasksURL,
|
||||
}
|
||||
|
||||
return m.SendTemplate(to, subject, "welcome.html", data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,4 +194,5 @@ type Config struct {
|
|||
Port string
|
||||
Env string // "development" | "production"
|
||||
JWTSecret []byte
|
||||
Mailer *Mailer
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
{{define "welcome.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Dealspace</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #f5f5f5;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600; letter-spacing: 1px;">DEALSPACE</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 40px;">
|
||||
<h2 style="margin: 0 0 20px 0; color: #1a2744; font-size: 24px; font-weight: 600;">
|
||||
Welcome to Dealspace{{if .RecipientName}}, {{.RecipientName}}{{end}}! 🎉
|
||||
</h2>
|
||||
<p style="margin: 0 0 25px 0; color: #4a5568; font-size: 16px; line-height: 1.6;">
|
||||
You're all set up and ready to go. Here are three quick tips to get you started:
|
||||
</p>
|
||||
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📥 Your inbox is your home</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Everything assigned to you appears in your task inbox. Start there each day to see what needs your attention.</p>
|
||||
</div>
|
||||
<div style="margin: 0 0 20px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">📋 Requests, not folders</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">We organize by requests, not file folders. Each request tracks its own status, comments, and documents — all in one place.</p>
|
||||
</div>
|
||||
<div style="margin: 0 0 25px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
|
||||
<h3 style="margin: 0 0 8px 0; color: #1a2744; font-size: 16px; font-weight: 600;">🤖 Ask Aria</h3>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 14px; line-height: 1.5;">Not sure where something is? Ask Aria, our AI assistant. She can find documents, answer questions, and guide you through the process.</p>
|
||||
</div>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin: 30px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #c9a227; border-radius: 6px;">
|
||||
<a href="{{.TasksURL}}" style="display: inline-block; padding: 16px 32px; color: #1a2744; font-size: 16px; font-weight: 600; text-decoration: none;">Go to My Tasks</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin: 20px 0 0 0; color: #4a5568; font-size: 15px; line-height: 1.6;">We're excited to have you on board. Let's make this deal happen! 🚀</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color: #1a2744; padding: 30px 40px; text-align: center;">
|
||||
<p style="margin: 0 0 10px 0; color: #9ca3af; font-size: 12px;">Need help? Contact <a href="mailto:support@muskepo.com" style="color: #c9a227; text-decoration: none;">support@muskepo.com</a></p>
|
||||
<p style="margin: 0; color: #6b7280; font-size: 11px;">© 2026 Dealspace · <a href="#" style="color: #6b7280; text-decoration: none;">Privacy Policy</a> · <a href="#" style="color: #6b7280; text-decoration: none;">Terms of Service</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Loading…
Reference in New Issue