Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ REDIS_DB=0
# RabbitMQ
RABBITMQ_URL=amqp://guest:guest@localhost:5672/

# Frontend URL for invite links in emails (e.g. https://app.example.com). If unset, CORS_ORIGIN is used.
APP_BASE_URL=https://app.example.com

# MinIO (S3-compatible)
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY_ID=minioadmin
Expand Down
6 changes: 5 additions & 1 deletion api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ import (

"github.com/Devlaner/devlane/api/internal/config"
"github.com/Devlaner/devlane/api/internal/database"
"github.com/Devlaner/devlane/api/internal/mail"
"github.com/Devlaner/devlane/api/internal/minio"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/rabbitmq"
"github.com/Devlaner/devlane/api/internal/redis"
"github.com/Devlaner/devlane/api/internal/router"
"github.com/Devlaner/devlane/api/internal/store"
)

func main() {
Expand Down Expand Up @@ -93,7 +95,9 @@ func main() {
if chConsume, err := rmq.NewChannel(); err == nil {
defer chConsume.Close()
consumer := queue.NewConsumer(chConsume, log)
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(queue.NoopEmailSender(log)))
instanceSettingStore := store.NewInstanceSettingStore(db)
emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log)
consumer.Register(queue.QueueEmails, queue.HandleSendEmail(log, emailSender))
consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(queue.NoopWebhookDeliverer(log)))
if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks}); err != nil {
log.Warn("queue consumer", "error", err)
Expand Down
3 changes: 3 additions & 0 deletions api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type Config struct {
MigrationsPath string

CORSAllowOrigin string
// AppBaseURL is the public URL of the frontend (e.g. https://app.example.com). Used for invite links in emails. If empty, CORSAllowOrigin is used.
AppBaseURL string
}

func (c *Config) DSN() string {
Expand Down Expand Up @@ -84,6 +86,7 @@ func Load() (*Config, error) {
MinIOUseSSL: minioSSL,
MigrationsPath: getEnv("MIGRATIONS_PATH", "migrations"),
CORSAllowOrigin: getEnv("CORS_ORIGIN", "http://localhost:5173"),
AppBaseURL: getEnv("APP_BASE_URL", ""),
}

return cfg, nil
Expand Down
68 changes: 68 additions & 0 deletions api/internal/handler/invitation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package handler

import (
"net/http"
"strings"

"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
)

// InvitationHandler serves public invite-by-token endpoints (no auth).
type InvitationHandler struct {
Winv *store.WorkspaceInviteStore
Ws *store.WorkspaceStore
}

// GetInviteByToken returns workspace invite details by token for the invite landing page.
// GET /api/invitations/by-token/?token=...
func (h *InvitationHandler) GetInviteByToken(c *gin.Context) {
token := strings.TrimSpace(c.Query("token"))
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
return
}
inv, err := h.Winv.GetByToken(c.Request.Context(), token)
if err != nil || inv == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Invite not found or expired"})
return
}
w, err := h.Ws.GetByID(c.Request.Context(), inv.WorkspaceID)
if err != nil || w == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Workspace not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"workspace_name": w.Name,
"workspace_slug": w.Slug,
"email": inv.Email,
"invitation_id": inv.ID.String(),
})
}
Comment thread
martian56 marked this conversation as resolved.

// DeclineInviteByToken removes the invitation (Ignore flow). No auth required.
// POST /api/invitations/decline/ body: { "token": "..." }
func (h *InvitationHandler) DeclineInviteByToken(c *gin.Context) {
var body struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
return
}
token := strings.TrimSpace(body.Token)
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token required"})
return
}
inv, err := h.Winv.GetByToken(c.Request.Context(), token)
if err != nil || inv == nil {
c.Status(http.StatusNoContent)
return
}
if err := h.Winv.Delete(c.Request.Context(), inv.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decline invite"})
return
}
Comment thread
martian56 marked this conversation as resolved.
c.Status(http.StatusNoContent)
}
29 changes: 27 additions & 2 deletions api/internal/handler/workspace.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package handler

import (
"fmt"
"net/http"
"strings"

"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/queue"
"github.com/Devlaner/devlane/api/internal/service"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
Expand All @@ -13,8 +15,10 @@ import (

// WorkspaceHandler serves workspace and member/invite endpoints.
type WorkspaceHandler struct {
Workspace *service.WorkspaceService
Settings *store.InstanceSettingStore
Workspace *service.WorkspaceService
Settings *store.InstanceSettingStore
Queue *queue.Publisher // optional: enqueue invite emails
AppBaseURL string // optional: base URL for invite links (e.g. https://app.example.com)
}

// List returns the current user's workspaces.
Expand Down Expand Up @@ -373,6 +377,27 @@ func (h *WorkspaceHandler) CreateInvite(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create invite"})
return
}

// Enqueue invite email when queue and base URL are configured
if h.Queue != nil && h.AppBaseURL != "" {
w, _ := h.Workspace.GetBySlug(c.Request.Context(), slug, user.ID)
workspaceName := slug
if w != nil {
workspaceName = w.Name
}
inviteLink := strings.TrimSuffix(h.AppBaseURL, "/") + "/invite?token=" + inv.Token
subject := fmt.Sprintf("You're invited to join %s on Devlane", workspaceName)
bodyText := fmt.Sprintf("You have been invited to join the workspace \"%s\" on Devlane.\n\nAccept your invitation by visiting:\n%s\n\nIf you don't have an account yet, you can sign up at the same link.\n", workspaceName, inviteLink)
_ = h.Queue.PublishSendEmail(c.Request.Context(), queue.SendEmailPayload{
To: inv.Email,
Subject: subject,
Body: bodyText,
Kind: "workspace_invite",
InviteURL: inviteLink,
Extra: map[string]string{"workspace_slug": slug, "invite_id": inv.ID.String()},
})
}

c.JSON(http.StatusCreated, inv)
}

Expand Down
48 changes: 48 additions & 0 deletions api/internal/mail/logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package mail

import "log/slog"

// LogSendAttempt logs when an email send is about to be attempted.
// inviteURL is optional (e.g. workspace invite link); empty string is omitted from logs.
func LogSendAttempt(log *slog.Logger, to, subject, kind, inviteURL string) {
if log == nil {
return
}
attrs := []any{"to", to, "subject", subject, "kind", kind}
if inviteURL != "" {
attrs = append(attrs, "invite_url", inviteURL)
}
log.Info("mail send attempt", attrs...)
Comment thread
martian56 marked this conversation as resolved.
}

// LogSent logs successful email delivery.
func LogSent(log *slog.Logger, to, subject, inviteURL string) {
if log == nil {
return
}
attrs := []any{"to", to, "subject", subject}
if inviteURL != "" {
attrs = append(attrs, "invite_url", inviteURL)
}
log.Info("mail sent", attrs...)
}

// LogFailed logs a failed email send with error and optional invite URL.
func LogFailed(log *slog.Logger, to, subject, inviteURL string, err error) {
if log == nil {
return
}
attrs := []any{"to", to, "subject", subject, "error", err}
if inviteURL != "" {
attrs = append(attrs, "invite_url", inviteURL)
}
log.Error("mail send failed", attrs...)
}

// LogSkip logs when mail is skipped (e.g. not configured).
func LogSkip(log *slog.Logger, reason, to string, err error) {
if log == nil {
return
}
log.Warn("mail skip", "reason", reason, "to", to, "error", err)
}
100 changes: 100 additions & 0 deletions api/internal/mail/mail.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package mail

import (
"context"
"fmt"
"log/slog"
"net/smtp"
"strconv"
"strings"

"github.com/Devlaner/devlane/api/internal/crypto"
"github.com/Devlaner/devlane/api/internal/store"
)

type smtpSettings struct {
Host string
Port int
SenderEmail string
Security string
Username string
Password string
}

func getEmailSettings(ctx context.Context, s *store.InstanceSettingStore) (*smtpSettings, error) {
row, err := s.Get(ctx, "email")
if err != nil || row == nil {
return nil, fmt.Errorf("email settings not found")
}
v := row.Value
if v == nil {
return nil, fmt.Errorf("email settings empty")
}
host, _ := v["host"].(string)
port := 587
if p, ok := v["port"].(string); ok && p != "" {
if n, err := strconv.Atoi(p); err == nil {
port = n
}
}
if p, ok := v["port"].(float64); ok {
port = int(p)
}
sender, _ := v["sender_email"].(string)
security, _ := v["security"].(string)
username, _ := v["username"].(string)
passRaw, _ := v["password"].(string)
password := crypto.DecryptOrPlain(passRaw)
host = strings.TrimSpace(host)
if host == "" {
return nil, fmt.Errorf("email host not configured")
}
return &smtpSettings{
Host: host,
Port: port,
SenderEmail: strings.TrimSpace(sender),
Security: strings.TrimSpace(security),
Username: strings.TrimSpace(username),
Password: password,
}, nil
}

// NewSMTPEmailSender returns a sender that loads SMTP config from instance "email"
// settings and sends mail. If not configured or send fails, logs and returns error.
func NewSMTPEmailSender(instanceSettings *store.InstanceSettingStore, log *slog.Logger) func(ctx context.Context, to, subject, body string) error {
return func(ctx context.Context, to, subject, body string) error {
cfg, err := getEmailSettings(ctx, instanceSettings)
if err != nil {
LogSkip(log, "instance email not configured", to, err)
return err
}
from := cfg.SenderEmail
if from == "" {
from = cfg.Username
}
if from == "" {
LogSkip(log, "sender_email and username empty", to, fmt.Errorf("sender not set"))
return fmt.Errorf("sender email not configured")
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
msg := buildMessage(to, from, subject, body)
if err := smtp.SendMail(addr, auth, from, []string{to}, msg); err != nil {
LogFailed(log, to, subject, "", err)
return err
}
LogSent(log, to, subject, "")
return nil
Comment thread
martian56 marked this conversation as resolved.
Outdated
}
Comment thread
martian56 marked this conversation as resolved.
}

func buildMessage(to, from, subject, body string) []byte {
const crlf = "\r\n"
h := "To: " + to + crlf +
"From: " + from + crlf +
"Subject: " + subject + crlf +
"Content-Type: text/plain; charset=UTF-8" + crlf +
"MIME-Version: 1.0" + crlf +
crlf
return []byte(h + body)
Comment thread
martian56 marked this conversation as resolved.
}
15 changes: 12 additions & 3 deletions api/internal/queue/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"log/slog"

"github.com/Devlaner/devlane/api/internal/mail"
amqp "github.com/rabbitmq/amqp091-go"
)

Expand Down Expand Up @@ -78,8 +79,8 @@ func (c *Consumer) defaultHandler(ctx context.Context, queue string, body []byte

// --- Handlers for email and webhook (can be extended with real SMTP/HTTP) ---

// HandleSendEmail parses send_email task and runs the given sender.
func HandleSendEmail(sender func(ctx context.Context, to, subject, body string) error) TaskHandler {
// HandleSendEmail parses send_email task, logs attempt/result (including invite_url), and runs the given sender.
func HandleSendEmail(log *slog.Logger, sender func(ctx context.Context, to, subject, body string) error) TaskHandler {
return func(ctx context.Context, queue string, body []byte) error {
var msg struct {
Type string `json:"type"`
Expand All @@ -91,7 +92,15 @@ func HandleSendEmail(sender func(ctx context.Context, to, subject, body string)
if msg.Type != TaskSendEmail {
return nil
}
return sender(ctx, msg.Payload.To, msg.Payload.Subject, msg.Payload.Body)
p := &msg.Payload
mail.LogSendAttempt(log, p.To, p.Subject, p.Kind, p.InviteURL)
err := sender(ctx, p.To, p.Subject, p.Body)
if err != nil {
mail.LogFailed(log, p.To, p.Subject, p.InviteURL, err)
return err
}
mail.LogSent(log, p.To, p.Subject, p.InviteURL)
return nil
}
}

Expand Down
11 changes: 6 additions & 5 deletions api/internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@ const (

// SendEmailPayload is the payload for send_email task.
type SendEmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
Kind string `json:"kind"` // e.g. forgot_password, workspace_invite, project_invite
Extra map[string]string `json:"extra,omitempty"`
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
Kind string `json:"kind"` // e.g. forgot_password, workspace_invite, project_invite
InviteURL string `json:"invite_url,omitempty"` // optional; logged for debugging (e.g. workspace invite link)
Extra map[string]string `json:"extra,omitempty"`
}

// WebhookPayload is the payload for webhook_deliver task.
Expand Down
Loading