Get real mailbox size from dovecot

- Add mail.GetQuota() using doveadm quota get
- Fix UserWithQuota response struct
- Fix frontend NaN display with Unknown fallback
- Update field names to camelCase (usedQuota)
This commit is contained in:
Christoph Haas 2026-03-23 00:24:40 +01:00
parent 16b6d9df60
commit c5a9149220
3 changed files with 68 additions and 5 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/imc-vibe/backend/internal/db" "github.com/imc-vibe/backend/internal/db"
"github.com/imc-vibe/backend/internal/mail"
) )
type UserHandler struct { type UserHandler struct {
@ -30,6 +31,13 @@ type UpdateUserRequest struct {
Quota int64 `json:"quota,omitempty"` Quota int64 `json:"quota,omitempty"`
} }
type UserWithQuota struct {
ID uint `json:"id"`
Email string `json:"email"`
Quota int64 `json:"quota"`
UsedQuota *int64 `json:"usedQuota"`
}
func (h *UserHandler) List(c *gin.Context) { func (h *UserHandler) List(c *gin.Context) {
domainName := c.Param("name") domainName := c.Param("name")
if domainName == "" { if domainName == "" {
@ -61,7 +69,22 @@ func (h *UserHandler) List(c *gin.Context) {
return return
} }
Success(c, users) result := make([]UserWithQuota, len(users))
for i, user := range users {
result[i] = UserWithQuota{
ID: user.ID,
Email: user.Email,
Quota: user.Quota,
}
quota, err := mail.GetQuota(user.Email)
if err == nil && quota != nil {
result[i].Quota = quota.Limit
result[i].UsedQuota = &quota.Used
}
}
Success(c, result)
} }
func (h *UserHandler) Get(c *gin.Context) { func (h *UserHandler) Get(c *gin.Context) {

View file

@ -0,0 +1,40 @@
package mail
import (
"bufio"
"os/exec"
"regexp"
"strconv"
"strings"
)
type Quota struct {
Used int64 `json:"used"` // Used storage in bytes
Limit int64 `json:"limit"` // Storage limit in bytes (-1 = unlimited)
}
func GetQuota(email string) (*Quota, error) {
cmd := exec.Command("/usr/bin/doveadm", "quota", "get", "-u", email)
output, err := cmd.Output()
if err != nil {
return nil, err
}
return parseDoveadmQuota(output)
}
var quotaLineRegex = regexp.MustCompile(`STORAGE\s+(\d+)\s+(\d+)`)
func parseDoveadmQuota(output []byte) (*Quota, error) {
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
matches := quotaLineRegex.FindStringSubmatch(line)
if len(matches) == 3 {
used, _ := strconv.ParseInt(matches[1], 10, 64)
limit, _ := strconv.ParseInt(matches[2], 10, 64)
return &Quota{Used: used, Limit: limit}, nil
}
}
return nil, nil
}

View file

@ -4,9 +4,8 @@
interface User { interface User {
id: number; id: number;
email: string; email: string;
domain_id: number;
quota: number; quota: number;
used_quota: number; usedQuota?: number;
} }
let users = $state<User[]>([]); let users = $state<User[]>([]);
@ -85,7 +84,8 @@
} }
} }
function formatQuota(bytes: number): string { function formatQuota(bytes: number | undefined | null): string {
if (bytes === undefined || bytes === null || isNaN(bytes)) return 'Unknown';
if (bytes === 0) return 'Default'; if (bytes === 0) return 'Default';
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
@ -127,7 +127,7 @@
<tr> <tr>
<td>{user.email}</td> <td>{user.email}</td>
<td>{formatQuota(user.quota)}</td> <td>{formatQuota(user.quota)}</td>
<td>{formatQuota(user.used_quota)}</td> <td>{formatQuota(user.usedQuota)}</td>
<td> <td>
<button class="danger" onclick={() => deleteUser(user.id)}>Delete</button> <button class="danger" onclick={() => deleteUser(user.id)}>Delete</button>
</td> </td>