Compare commits

...

5 Commits

Author SHA1 Message Date
m
e5387734aa fix: use mgmt@msbls.de as default MAIL_FROM (alias now exists) 2026-03-30 17:28:11 +02:00
m
6cb87c6868 feat: replace m CLI email with direct SMTP over TLS
The m CLI isn't available in Docker containers. Replace exec.Command("m", "mail", "send")
with direct SMTP using crypto/tls + net/smtp (implicit TLS on port 465).

Env vars: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM
Gracefully skips sending if SMTP is not configured.

Note: mgmt@msbls.de rejected by Hostinger as not owned by mail@msbls.de.
Default from address set to mail@msbls.de until alias is created.
2026-03-30 17:23:54 +02:00
m
d38719db2f fix: add email field to UserTenant TypeScript type 2026-03-30 17:19:15 +02:00
m
b21efccfb5 fix: add MAIL_FROM env (default mgmt@msbls.de) + graceful fallback when m CLI unavailable 2026-03-30 17:10:25 +02:00
m
f51d189a3b fix: show member email instead of UUID in team management 2026-03-30 17:09:14 +02:00
6 changed files with 102 additions and 13 deletions

View File

@@ -14,6 +14,13 @@ type Config struct {
SupabaseJWTSecret string SupabaseJWTSecret string
AnthropicAPIKey string AnthropicAPIKey string
FrontendOrigin string FrontendOrigin string
// SMTP settings (optional — email sending disabled if SMTPHost is empty)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
MailFrom string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -26,6 +33,12 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"), SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"), AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"), FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnv("SMTP_PORT", "465"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPass: os.Getenv("SMTP_PASS"),
MailFrom: getEnv("MAIL_FROM", "mgmt@msbls.de"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {

View File

@@ -20,6 +20,7 @@ type UserTenant struct {
UserID uuid.UUID `db:"user_id" json:"user_id"` UserID uuid.UUID `db:"user_id" json:"user_id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
Role string `db:"role" json:"role"` Role string `db:"role" json:"role"`
Email string `db:"email" json:"email"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
} }

View File

@@ -2,9 +2,12 @@ package services
import ( import (
"context" "context"
"crypto/tls"
"fmt" "fmt"
"log/slog" "log/slog"
"os/exec" "net"
"net/smtp"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -457,18 +460,85 @@ type UpdatePreferencesInput struct {
DailyDigest bool `json:"daily_digest"` DailyDigest bool `json:"daily_digest"`
} }
// SendEmail sends an email using the `m mail send` CLI command. // SendEmail sends an email via direct SMTP over TLS.
// Requires SMTP_HOST, SMTP_USER, SMTP_PASS env vars. Falls back to no-op if unconfigured.
func SendEmail(to, subject, body string) error { func SendEmail(to, subject, body string) error {
cmd := exec.Command("m", "mail", "send", host := os.Getenv("SMTP_HOST")
"--to", to, port := os.Getenv("SMTP_PORT")
"--subject", subject, user := os.Getenv("SMTP_USER")
"--body", body, pass := os.Getenv("SMTP_PASS")
"--yes") from := os.Getenv("MAIL_FROM")
output, err := cmd.CombinedOutput()
if err != nil { if port == "" {
return fmt.Errorf("m mail send failed: %w (output: %s)", err, string(output)) port = "465"
} }
slog.Info("email sent", "to", to, "subject", subject) if from == "" {
from = "mgmt@msbls.de"
}
if host == "" || user == "" || pass == "" {
slog.Warn("SMTP not configured, skipping email", "to", to, "subject", subject)
return nil
}
// Build RFC 2822 message
msg := fmt.Sprintf("From: \"KanzlAI-mGMT\" <%s>\r\n"+
"To: %s\r\n"+
"Subject: [KanzlAI] %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: text/plain; charset=utf-8\r\n"+
"Date: %s\r\n"+
"\r\n%s",
from, to, subject,
time.Now().Format(time.RFC1123Z),
body)
addr := net.JoinHostPort(host, port)
// Connect with implicit TLS (port 465)
tlsConfig := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("smtp tls dial: %w", err)
}
client, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp new client: %w", err)
}
defer client.Close()
// Authenticate
auth := smtp.PlainAuth("", user, pass, host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
// Send
if err := client.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write([]byte(msg)); err != nil {
return fmt.Errorf("smtp write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
if err := client.Quit(); err != nil {
slog.Warn("smtp quit error (non-fatal)", "error", err)
}
slog.Info("email sent via SMTP", "from", from, "to", to, "subject", subject)
return nil return nil
} }

View File

@@ -139,7 +139,11 @@ func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) { func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
var members []models.UserTenant var members []models.UserTenant
err := s.db.SelectContext(ctx, &members, err := s.db.SelectContext(ctx, &members,
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`, `SELECT ut.user_id, ut.tenant_id, ut.role, ut.created_at, COALESCE(au.email, '') as email
FROM user_tenants ut
LEFT JOIN auth.users au ON au.id = ut.user_id
WHERE ut.tenant_id = $1
ORDER BY ut.created_at`,
tenantID, tenantID,
) )
if err != nil { if err != nil {

View File

@@ -164,7 +164,7 @@ export function TeamSettings() {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-neutral-900"> <p className="text-sm font-medium text-neutral-900">
{member.user_id.slice(0, 8)}... {member.email || member.user_id.slice(0, 8) + "..."}
</p> </p>
<p className="text-xs text-neutral-500">{roleInfo.label}</p> <p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div> </div>

View File

@@ -15,6 +15,7 @@ export interface UserTenant {
user_id: string; user_id: string;
tenant_id: string; tenant_id: string;
role: string; role: string;
email: string;
created_at: string; created_at: string;
} }