diff --git a/lib/mailer_test.go b/lib/mailer_test.go new file mode 100644 index 0000000..5813272 --- /dev/null +++ b/lib/mailer_test.go @@ -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, "") { + 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", "test") + 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...'") + } +} diff --git a/lib/notify.go b/lib/notify.go index 930d4e8..3b0e878 100644 --- a/lib/notify.go +++ b/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) +} diff --git a/lib/types.go b/lib/types.go index 216069b..82cb7ab 100644 --- a/lib/types.go +++ b/lib/types.go @@ -194,4 +194,5 @@ type Config struct { Port string Env string // "development" | "production" JWTSecret []byte + Mailer *Mailer } diff --git a/portal/emails/welcome.html b/portal/emails/welcome.html new file mode 100644 index 0000000..2f08a32 --- /dev/null +++ b/portal/emails/welcome.html @@ -0,0 +1,61 @@ +{{define "welcome.html"}} + + + + + + Welcome to Dealspace + + + + + + +
+ + + + + + + + + + +
+

DEALSPACE

+
+

+ Welcome to Dealspace{{if .RecipientName}}, {{.RecipientName}}{{end}}! ๐ŸŽ‰ +

+

+ You're all set up and ready to go. Here are three quick tips to get you started: +

+
+

๐Ÿ“ฅ Your inbox is your home

+

Everything assigned to you appears in your task inbox. Start there each day to see what needs your attention.

+
+
+

๐Ÿ“‹ Requests, not folders

+

We organize by requests, not file folders. Each request tracks its own status, comments, and documents โ€” all in one place.

+
+
+

๐Ÿค– Ask Aria

+

Not sure where something is? Ask Aria, our AI assistant. She can find documents, answer questions, and guide you through the process.

+
+ + + + +
+ Go to My Tasks +
+

We're excited to have you on board. Let's make this deal happen! ๐Ÿš€

+
+

Need help? Contact support@muskepo.com

+

ยฉ 2026 Dealspace ยท Privacy Policy ยท Terms of Service

+
+
+ + +{{end}}