Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 .github/workflows/api-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ jobs:
- name: Verify dependencies
run: go mod verify

- name: Check formatting
run: test -z "$(gofmt -l .)"

- name: Build
run: go build -v ./cmd/api/...

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/ui-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: UI CI

on:
pull_request:
branches: [main]
paths:
- 'ui/**'
- '.github/workflows/ui-ci.yml'

defaults:
run:
working-directory: ui

jobs:
lint-and-format:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Check formatting
run: npm run format:check

- name: Build
run: npm run build
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(),
})
}

// 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
}
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...)
}

// 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)
}
Loading