package main import ( "bytes" "crypto/rand" "errors" "os" "path/filepath" "strconv" "strings" "time" "crypto/ed25519" "database/sql" "encoding/json" "html/template" "io/fs" "net/http" "net/url" library "git.ailur.dev/ailur/fg-library/v2" nucleusLibrary "git.ailur.dev/ailur/fg-nucleus-library" "github.com/emersion/go-sasl" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" _ "github.com/lib/pq" ) var ServiceInformation = library.Service{ Name: "kittemail", Permissions: library.Permissions{ Authenticate: true, // This service does require authentication Database: true, // This service does require database access BlobStorage: true, // This service does require reserving blob storage space InterServiceCommunication: true, // This service does require inter-service communication Resources: true, // This service does require its HTTP templates and static files }, ServiceID: uuid.MustParse("338350fc-14d5-469d-999b-950033fb6b7c"), } func getUUIDBytes(uuidString string) []byte { uuidBytes := uuid.MustParse(uuidString) return uuidBytes[:] } func randomChars(length int) (string, error) { var saltChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" if length <= 0 { return "", errors.New("salt length must be greater than 0") } salt := make([]byte, length) randomBytes := make([]byte, length) _, err := rand.Read(randomBytes) if err != nil { return "", err } for i := range salt { salt[i] = saltChars[int(randomBytes[i])%len(saltChars)] } return string(salt), nil } func logFunc(message string, messageType uint64, information library.ServiceInitializationInformation) { // Log the message to the logger service information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), // Logger service MessageType: messageType, SentAt: time.Now(), Message: message, } } func renderTemplate(statusCode int, w http.ResponseWriter, data any, templatePath string, information library.ServiceInitializationInformation) { var err error var requestedTemplate *template.Template // Output ls of the resource directory requestedTemplate, err = template.ParseFS(information.ResourceDir, "templates/"+templatePath) if err != nil { logFunc(err.Error(), 2, information) renderString(500, w, "Sorry, something went wrong on our end. Error code: 01. Please report to the administrator.", information) } else { w.WriteHeader(statusCode) err = requestedTemplate.Execute(w, data) if err != nil { logFunc(err.Error(), 2, information) renderString(500, w, "Sorry, something went wrong on our end. Error code: 02. Please report to the administrator.", information) } } } func renderString(statusCode int, w http.ResponseWriter, data string, information library.ServiceInitializationInformation) { w.WriteHeader(statusCode) _, err := w.Write([]byte(data)) if err != nil { logFunc(err.Error(), 2, information) } } func renderJSON(statusCode int, w http.ResponseWriter, data any, information library.ServiceInitializationInformation) { w.WriteHeader(statusCode) err := json.NewEncoder(w).Encode(data) if err != nil { logFunc(err.Error(), 2, information) } } func verifyJwt(token string, publicKey ed25519.PublicKey, conn library.Database, checkInUsers bool) (jwt.MapClaims, bool) { parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { return publicKey, nil }) if err != nil { println(err.Error()) return nil, false } if !parsedToken.Valid { return nil, false } claims, ok := parsedToken.Claims.(jwt.MapClaims) if !ok { return nil, false } // Check if the token expired date, err := claims.GetExpirationTime() if err != nil || date.Before(time.Now()) || claims["sub"] == nil || claims["isOpenID"] == nil || claims["isOpenID"].(bool) { return claims, false } // Check if the token is in users if checkInUsers { var idCheck []byte err = conn.DB.QueryRow("SELECT id FROM users WHERE id = $1", getUUIDBytes(claims["sub"].(string))).Scan(&idCheck) if err != nil || claims["sub"] != uuid.Must(uuid.FromBytes(idCheck)).String() { return claims, false } } return claims, true } func initDb(conn library.Database, information library.ServiceInitializationInformation) { if conn.DBType == library.Sqlite { // Create the users table _, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BLOB isPrimary KEY, subscription INTEGER NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") if err != nil { logFunc(err.Error(), 3, information) } // Create the emails table _, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS emails (username TEXT NOT NULL, address TEXT NOT NULL, isPrimary INTEGER NOT NULL, owner BLOB NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, password TEXT NOT NULL)") if err != nil { logFunc(err.Error(), 3, information) } } else { // Create the users table _, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BYTEA isPrimary KEY, subscription INTEGER NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP)") if err != nil { logFunc(err.Error(), 3, information) } // Create the emails table _, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS emails (username TEXT NOT NULL, address TEXT NOT NULL, isPrimary BOOLEAN NOT NULL, owner BYTEA NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, password TEXT NOT NULL)") if err != nil { logFunc(err.Error(), 3, information) } } } func getSuperuserCommand(information library.ServiceInitializationInformation) string { if os.Getuid() != 0 { // Check if pkexec, sudo or doas is installed _, err := os.Stat("/usr/bin/pkexec") if os.IsNotExist(err) { _, err = os.Stat("/usr/bin/sudo") if os.IsNotExist(err) { _, err = os.Stat("/usr/bin/doas") if os.IsNotExist(err) { logFunc("No superuser command found", 3, information) } else if err != nil { logFunc(err.Error(), 3, information) } else { return "doas" } } else if err != nil { logFunc(err.Error(), 3, information) } else { return "sudo" } } else if err != nil { logFunc(err.Error(), 3, information) } else { return "pkexec" } } return "" } func startDovecotListener(information library.ServiceInitializationInformation, database library.Database) { // Create a new SASL server server := sasl.NewPlainServer(func(identity, username, password string) error { // Check if the user exists var idCheck []byte err := database.DB.QueryRow("SELECT id FROM users WHERE id = $1", getUUIDBytes(username)).Scan(&idCheck) if err != nil || !bytes.Equal(idCheck, getUUIDBytes(username)) { return errors.New("user not found") } // Check if the password is correct var dbPassword string err = database.DB.QueryRow("SELECT password FROM emails WHERE owner = $1 AND isPrimary = true", getUUIDBytes(username)).Scan(&dbPassword) if err != nil || dbPassword != password { return errors.New("invalid password") } return nil }) server.Next() } func Main(information library.ServiceInitializationInformation) { var doveConn *sql.DB // doveConn, err := sql.Open("postgres", information.Configuration["connectionString"].(string)) // if err != nil { // logFunc(err.Error(), 3, information) // } var err error var conn library.Database hostName := information.Configuration["hostName"].(string) // Initiate a connection to the database // Call service ID 1 to get the database connection information information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), // Service initialization service MessageType: 1, // Request connection information SentAt: time.Now(), Message: nil, } // Wait for the response response := <-information.Inbox if response.MessageType == 2 { // This is the connection information // Set up the database connection conn = response.Message.(library.Database) initDb(conn, information) } else { // This is an error message // Log the error message to the logger service logFunc(response.Message.(error).Error(), 3, information) } // Ask the authentication service for the public key information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service MessageType: 2, // Request public key SentAt: time.Now(), Message: nil, } var publicKey ed25519.PublicKey = nil // 3 second timeout go func() { time.Sleep(3 * time.Second) if publicKey == nil { logFunc("Timeout while waiting for the public key from the authentication service", 3, information) } }() // Wait for the response response = <-information.Inbox if response.MessageType == 2 { // This is the public key publicKey = response.Message.(ed25519.PublicKey) } else { // This is an error message // Log the error message to the logger service logFunc(response.Message.(error).Error(), 3, information) } // Ask the authentication service for the OAuth host name information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service MessageType: 0, // Request OAuth host name SentAt: time.Now(), Message: nil, } var oauthHostName string // 3 second timeout go func() { time.Sleep(3 * time.Second) if oauthHostName == "" { logFunc("Timeout while waiting for the OAuth host name from the authentication service", 3, information) } }() // Wait for the response response = <-information.Inbox if response.MessageType == 0 { // This is the OAuth host name oauthHostName = response.Message.(string) } else { // This is an error message // Log the error message to the logger service logFunc(response.Message.(error).Error(), 3, information) } // Ask the authentication service to create a new OAuth2 client urlPath, err := url.JoinPath(hostName, "/oauth") if err != nil { logFunc(err.Error(), 3, information) } information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service MessageType: 1, // Create OAuth2 client SentAt: time.Now(), Message: nucleusLibrary.OAuthInformation{ Name: "Kittemail", RedirectUri: urlPath, KeyShareUri: "", Scopes: []string{"openid"}, }, } var oauthResponse nucleusLibrary.OAuthResponse // 3 second timeout go func() { time.Sleep(3 * time.Second) if oauthResponse == (nucleusLibrary.OAuthResponse{}) { logFunc("Timeout while waiting for the OAuth response from the authentication service", 3, information) } }() // Wait for the response response = <-information.Inbox switch response.MessageType { case 0: // Success, set the OAuth response oauthResponse = response.Message.(nucleusLibrary.OAuthResponse) logFunc("Initialized with App ID: "+oauthResponse.AppID, 0, information) case 1: // An error which is their fault logFunc(response.Message.(error).Error(), 3, information) case 2: // An error which is our fault logFunc(response.Message.(error).Error(), 3, information) default: // An unknown error logFunc("Unknown error", 3, information) } // Start the Dovecot listener go startDovecotListener(information, conn) // Set up the router router := information.Router // Set up the static routes staticDir, err := fs.Sub(information.ResourceDir, "static") if err != nil { logFunc(err.Error(), 3, information) } else { router.Handle("/kt-static/*", http.StripPrefix("/kt-static/", http.FileServerFS(staticDir))) } // Set up the API routes router.Post("/api/signup", func(w http.ResponseWriter, r *http.Request) { var request struct { Username string `json:"username"` Domain string `json:"domain"` Token string `json:"token"` Storage float64 `json:"storage"` } err := json.NewDecoder(r.Body).Decode(&request) if err != nil { renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information) return } if request.Storage < 0 { renderJSON(400, w, map[string]interface{}{"error": "Invalid storage"}, information) return } // Validate if the username is valid if len(request.Username) < 3 || len(request.Username) > 20 { renderJSON(400, w, map[string]interface{}{"error": "Invalid username"}, information) return } // Validate if the domain is valid switch request.Domain { case "cta.social", "ailur.dev": break default: renderJSON(400, w, map[string]interface{}{"error": "Invalid domain"}, information) return } // Check if the user is authenticated claims, ok := verifyJwt(request.Token, publicKey, conn, false) if !ok { renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information) return } // Check if the user already exists userId := uuid.MustParse(claims["sub"].(string)) var idCheck []byte err = conn.DB.QueryRow("SELECT id FROM users WHERE id = $1", userId[:]).Scan(&idCheck) if err == nil && bytes.Equal(idCheck, userId[:]) { renderJSON(409, w, map[string]interface{}{"error": "User already exists"}, information) return } // Check if the email already exists var emailCheck string err = conn.DB.QueryRow("SELECT address FROM emails WHERE username = $1 AND address = $2", request.Username, request.Domain).Scan(&emailCheck) if err == nil && emailCheck == request.Domain { renderJSON(409, w, map[string]interface{}{"error": "Email already exists"}, information) return } // Reserve the space for the user information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), // Blob storage service MessageType: 2, // Reserve space SentAt: time.Now(), Message: nucleusLibrary.Quota{ User: userId, Bytes: int64(request.Storage), }, } // 3 second timeout var fileSpaceReserved bool go func() { time.Sleep(3 * time.Second) if !fileSpaceReserved { logFunc("Timeout while waiting for the file space to be reserved", 3, information) renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "00"}, information) // Panic to stop the function panic("Timeout while waiting for the file space to be reserved") } }() // Wait for the response response = <-information.Inbox if response.MessageType == 0 { // Success fileSpaceReserved = true } else if response.MessageType == 1 { // An error renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "01"}, information) return } else if response.MessageType == 2 { // Invalid request renderJSON(400, w, map[string]interface{}{"error": response.Message.(error).Error()}, information) return } // Insert the user into the database _, err = conn.DB.Exec("INSERT INTO users (id, subscription) VALUES ($1, 0)", userId[:]) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "02"}, information) return } // Insert the email into the database password, err := randomChars(16) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information) return } _, err = conn.DB.Exec("INSERT INTO emails (username, address, isPrimary, owner, password) VALUES ($1, $2, true, $3, $4)", request.Username, request.Domain, userId[:], password) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information) return } // Contact dovecot to create the user _, err = doveConn.Exec("INSERT INTO users (email, maildir, quota_rule) VALUES ($1, $2, $3)", request.Username+"@"+request.Domain, userId.String(), "*:storage="+strconv.FormatInt(int64(request.Storage), 10)+"B", userId) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "05"}, information) return } renderJSON(200, w, map[string]interface{}{"password": password}, information) }) router.Post("/api/email/info", func(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` Username string `json:"username"` Address string `json:"address"` } err := json.NewDecoder(r.Body).Decode(&request) if err != nil { renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information) return } // Check if the user is authenticated claims, ok := verifyJwt(request.Token, publicKey, conn, true) if !ok { renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information) return } // Get the email information var username, address, password string var isPrimary bool err = conn.DB.QueryRow("SELECT username, address, password, isPrimary FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, getUUIDBytes(claims["sub"].(string))).Scan(&username, &address, &password, &isPrimary) if err != nil { renderJSON(404, w, map[string]interface{}{"error": "Email not found"}, information) return } // Return the email information renderJSON(200, w, map[string]interface{}{ "username": username, "address": address, "password": password, "isPrimary": isPrimary, }, information) }) router.Post("/api/email/resetLoginCode", func(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` Username string `json:"username"` Address string `json:"address"` } err := json.NewDecoder(r.Body).Decode(&request) if err != nil { renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information) return } // Check if the user is authenticated claims, ok := verifyJwt(request.Token, publicKey, conn, true) if !ok { renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information) return } // Generate a new password password, err := randomChars(16) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "05"}, information) return } // Change the password _, err = conn.DB.Exec("UPDATE emails SET password = $1 WHERE username = $2 AND address = $3 AND owner = $4", password, request.Username, request.Address, getUUIDBytes(claims["sub"].(string))) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "06"}, information) return } renderJSON(200, w, nil, information) }) router.Post("/api/email/delete", func(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` Username string `json:"username"` Address string `json:"address"` } err := json.NewDecoder(r.Body).Decode(&request) if err != nil { renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information) return } // Check if the user is authenticated claims, ok := verifyJwt(request.Token, publicKey, conn, true) if !ok { renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information) return } userID := uuid.MustParse(claims["sub"].(string)) // Check if it's the isPrimary email var isPrimary bool err = conn.DB.QueryRow("SELECT isPrimary FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, userID[:]).Scan(&isPrimary) if err != nil { renderJSON(404, w, map[string]interface{}{"error": "Email not found"}, information) return } if !isPrimary { // Delete the email _, err = conn.DB.Exec("DELETE FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, userID[:]) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "07"}, information) return } } else { // Get the user's quota var quota string err = doveConn.QueryRow("SELECT quota_rule FROM users WHERE id = $1", userID.String()).Scan("a) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "08"}, information) return } // Parse the quota quotaInt, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(quota, "*:storage="), "B"), 10, 64) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "09"}, information) return } // Delete the maildir _, err = doveConn.Exec("DELETE FROM users WHERE maildir = $1", userID.String()) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "08"}, information) return } err = os.RemoveAll(filepath.Join("/home/mailboxes/maildir/", userID.String())) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "09"}, information) return } // Delete the emails _, err = conn.DB.Exec("DELETE FROM emails WHERE owner = $1", userID[:]) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0A"}, information) return } // Delete the user _, err = conn.DB.Exec("DELETE FROM users WHERE id = $1", userID[:]) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0B"}, information) return } // Release the space information.Outbox <- library.InterServiceMessage{ ServiceID: ServiceInformation.ServiceID, ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), // Blob storage service MessageType: 2, // Add space, but we can use a negative number to release space SentAt: time.Now(), Message: nucleusLibrary.Quota{ User: userID, Bytes: -quotaInt, }, } // 3 second timeout var fileSpaceReleased bool go func() { time.Sleep(3 * time.Second) if !fileSpaceReleased { logFunc("Timeout while waiting for the file space to be released", 3, information) renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0C"}, information) // Panic to stop the function panic("Timeout while waiting for the file space to be released") } }() // Wait for the response response = <-information.Inbox if response.MessageType == 0 { // Success fileSpaceReleased = true } } renderJSON(200, w, nil, information) }) router.Post("/api/email/list", func(w http.ResponseWriter, r *http.Request) { var request struct { Token string `json:"token"` } err := json.NewDecoder(r.Body).Decode(&request) if err != nil { renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information) return } // Check if the user is authenticated claims, ok := verifyJwt(request.Token, publicKey, conn, true) if !ok { renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information) return } // Get the emails rows, err := conn.DB.Query("SELECT username, address, isPrimary FROM emails WHERE owner = $1", getUUIDBytes(claims["sub"].(string))) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0D"}, information) return } var emails []map[string]interface{} for rows.Next() { var username, address string var isPrimary bool err = rows.Scan(&username, &address, &isPrimary) if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0E"}, information) return } emails = append(emails, map[string]interface{}{ "username": username, "address": address, "isPrimary": isPrimary, }) } err = rows.Close() if err != nil { renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0F"}, information) return } renderJSON(200, w, emails, information) }) // TODO: Once OAuth2 RFC is approved and all the EMail clients support it, switch out the Email-specific password for OAuth2 // TODO: Add automatic alias addition, deletion and listing // TODO: Add subscription management when Ailur becomes incorporated // Set up the routes router.Get("/", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, nil, "index.html", information) }) router.Get("/signup", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, nil, "signup.html", information) }) router.Get("/oauth", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, map[string]interface{}{ "ClientId": oauthResponse.AppID, "AuthorizationUri": oauthHostName, }, "oauth.html", information) }) router.Get("/settings", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, nil, "settings.html", information) }) router.Get("/quickstart", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, nil, "quickstart.html", information) }) router.Get("/autoconfig.xml", func(w http.ResponseWriter, r *http.Request) { renderTemplate(200, w, nil, "autoconfig.xml", information) }) }