From a7783312ac1617a9f94d9b9d89efe520d5d07f20 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Mon, 30 Mar 2026 17:54:53 +0200
Subject: [PATCH 01/11] feat(api): update alias.go
---
api/internal/transport/api/alias.go | 80 ++++++++++++++++++++++++++++
api/internal/transport/api/routes.go | 1 +
2 files changed, 81 insertions(+)
diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go
index 1daf509..f3531cd 100644
--- a/api/internal/transport/api/alias.go
+++ b/api/internal/transport/api/alias.go
@@ -2,6 +2,9 @@ package api
import (
"context"
+ "encoding/csv"
+ "io"
+ "log"
"strconv"
"strings"
@@ -122,6 +125,83 @@ func (h *Handler) GetAliases(c *fiber.Ctx) error {
return c.JSON(list)
}
+func (h *Handler) ImportAliases(c *fiber.Ctx) error {
+ // userID := auth.GetUserID(c)
+
+ // Get uploaded file
+ file, err := c.FormFile("file")
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{
+ "error": "No file uploaded",
+ })
+ }
+
+ f, err := file.Open()
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{
+ "error": "Failed to open file",
+ })
+ }
+ defer f.Close()
+
+ // Initialize CSV reader
+ reader := csv.NewReader(f)
+
+ // Skip the header row
+ _, err = reader.Read()
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{
+ "error": "Failed to read CSV header",
+ })
+ }
+
+ var rows []AliasReq
+
+ // Iterate through rows
+ for {
+ record, err := reader.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{"error": "Error reading CSV row"})
+ }
+
+ // record[0] = alias, record[1] = description, record[2] = enabled, record[3] = recipients
+ fullAlias := record[0]
+ parts := strings.Split(fullAlias, "@")
+
+ var local, domain string
+ if len(parts) == 2 {
+ local = parts[0]
+ domain = parts[1]
+ }
+
+ req := AliasReq{
+ Description: record[1],
+ Enabled: strings.ToLower(record[2]) == "true",
+ Recipients: record[3],
+ LocalPart: local,
+ Domain: domain,
+ Format: model.AliasFormatCustom,
+ }
+
+ // Validate alias row
+ err = h.Validator.Struct(req)
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{
+ "error": ErrInvalidRequest,
+ })
+ }
+
+ log.Printf("Parsed alias: %+v\n", req)
+
+ rows = append(rows, req)
+ }
+
+ return nil
+}
+
// @Summary Export aliases
// @Description Export all aliases as CSV
// @Tags alias
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index b5698bf..980d80c 100644
--- a/api/internal/transport/api/routes.go
+++ b/api/internal/transport/api/routes.go
@@ -85,6 +85,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) {
v1.Get("/alias/:id", h.GetAlias)
v1.Get("/aliases", h.GetAliases)
+ v1.Get("/aliases/import", h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
From 087ade166b40a5c817c4edcb842281499435b8bf Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 31 Mar 2026 11:14:03 +0200
Subject: [PATCH 02/11] feat(service): update alias.go
---
api/internal/model/alias.go | 10 ++++++++
api/internal/service/alias.go | 36 ++++++++++++++++++++++++++++
api/internal/transport/api/alias.go | 37 +++++++++++++++++++----------
3 files changed, 71 insertions(+), 12 deletions(-)
diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go
index f7922d5..6c94108 100644
--- a/api/internal/model/alias.go
+++ b/api/internal/model/alias.go
@@ -35,3 +35,13 @@ type AliasList struct {
Aliases []Alias `json:"aliases"`
Total int `json:"total"`
}
+
+type AliasImportReq struct {
+ Description string `json:"description"`
+ Enabled bool `json:"enabled"`
+ Recipients string `json:"recipients" validate:"required"`
+ FromName string `json:"from_name"`
+ Format string `json:"format"`
+ Domain string `json:"domain" validate:"required"`
+ LocalPart string `json:"local_part" validate:"omitempty,alphanum,min=6,max=24"`
+}
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index d200c81..04432a1 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -221,3 +221,39 @@ func (s *Service) FindAlias(email string) (model.Alias, error) {
return alias, nil
}
+
+func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImportReq, userID string) ([]model.Alias, error) {
+ var importedAliases []model.Alias
+
+ for _, req := range aliases {
+ rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID)
+ if err != nil || len(rcps) == 0 {
+ log.Printf("error importing alias: %s", err.Error())
+ continue
+ }
+
+ fqdn, err := s.GetVerifiedDomain(ctx, req.Domain, userID)
+ if err != nil {
+ log.Printf("error importing alias: %s", err.Error())
+ continue
+ }
+
+ alias := model.Alias{
+ UserID: userID,
+ Description: req.Description,
+ Enabled: req.Enabled,
+ Recipients: model.GetEmails(rcps),
+ FromName: req.FromName,
+ }
+
+ importedAlias, err := s.PostAlias(ctx, alias, req.Format, fqdn.Name, req.LocalPart)
+ if err != nil {
+ log.Printf("error importing alias: %s", err.Error())
+ continue
+ }
+
+ importedAliases = append(importedAliases, importedAlias)
+ }
+
+ return importedAliases, nil
+}
diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go
index f3531cd..1beed0e 100644
--- a/api/internal/transport/api/alias.go
+++ b/api/internal/transport/api/alias.go
@@ -20,6 +20,8 @@ var (
DeleteAliasSuccess = "Alias deleted successfully."
ErrInvalidDomain = "Selected domain is invalid."
ErrUnverifiedRcp = "The recipient address has not been verified."
+ ErrFailedImport = "Failed to import aliases. Please check the format and try again."
+ AliasImportSuccess = "Aliases imported successfully."
)
type AliasService interface {
@@ -29,6 +31,7 @@ type AliasService interface {
PostAlias(context.Context, model.Alias, string, string, string) (model.Alias, error)
UpdateAlias(context.Context, model.Alias) error
DeleteAlias(context.Context, string, string) error
+ ImportAliases(context.Context, []model.AliasImportReq, string) ([]model.Alias, error)
}
// @Summary Get alias
@@ -126,20 +129,20 @@ func (h *Handler) GetAliases(c *fiber.Ctx) error {
}
func (h *Handler) ImportAliases(c *fiber.Ctx) error {
- // userID := auth.GetUserID(c)
+ userID := auth.GetUserID(c)
// Get uploaded file
file, err := c.FormFile("file")
if err != nil {
return c.Status(400).JSON(fiber.Map{
- "error": "No file uploaded",
+ "error": ErrFailedImport,
})
}
f, err := file.Open()
if err != nil {
return c.Status(400).JSON(fiber.Map{
- "error": "Failed to open file",
+ "error": ErrFailedImport,
})
}
defer f.Close()
@@ -151,11 +154,11 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
_, err = reader.Read()
if err != nil {
return c.Status(400).JSON(fiber.Map{
- "error": "Failed to read CSV header",
+ "error": ErrFailedImport,
})
}
- var rows []AliasReq
+ var rows []model.AliasImportReq
// Iterate through rows
for {
@@ -164,7 +167,9 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
break
}
if err != nil {
- return c.Status(400).JSON(fiber.Map{"error": "Error reading CSV row"})
+ return c.Status(400).JSON(fiber.Map{
+ "error": ErrFailedImport,
+ })
}
// record[0] = alias, record[1] = description, record[2] = enabled, record[3] = recipients
@@ -177,10 +182,10 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
domain = parts[1]
}
- req := AliasReq{
+ req := model.AliasImportReq{
Description: record[1],
Enabled: strings.ToLower(record[2]) == "true",
- Recipients: record[3],
+ Recipients: strings.ReplaceAll(record[3], " ", ","),
LocalPart: local,
Domain: domain,
Format: model.AliasFormatCustom,
@@ -189,9 +194,7 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
// Validate alias row
err = h.Validator.Struct(req)
if err != nil {
- return c.Status(400).JSON(fiber.Map{
- "error": ErrInvalidRequest,
- })
+ continue
}
log.Printf("Parsed alias: %+v\n", req)
@@ -199,7 +202,17 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
rows = append(rows, req)
}
- return nil
+ aliases, err := h.Service.ImportAliases(c.Context(), rows, userID)
+ if err != nil {
+ return c.Status(400).JSON(fiber.Map{
+ "error": ErrFailedImport,
+ })
+ }
+
+ return c.JSON(fiber.Map{
+ "message": AliasImportSuccess,
+ "count": len(aliases),
+ })
}
// @Summary Export aliases
From 89159ac18f91a7b6b063fc001b5d1971733beb7e Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 31 Mar 2026 11:57:30 +0200
Subject: [PATCH 03/11] feat(service): update alias.go
---
api/internal/model/alias.go | 2 +-
api/internal/service/alias.go | 21 ++++++++++++++++-----
api/internal/transport/api/alias.go | 3 ---
3 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/api/internal/model/alias.go b/api/internal/model/alias.go
index 6c94108..022b124 100644
--- a/api/internal/model/alias.go
+++ b/api/internal/model/alias.go
@@ -43,5 +43,5 @@ type AliasImportReq struct {
FromName string `json:"from_name"`
Format string `json:"format"`
Domain string `json:"domain" validate:"required"`
- LocalPart string `json:"local_part" validate:"omitempty,alphanum,min=6,max=24"`
+ LocalPart string `json:"local_part" validate:"omitempty,min=6,max=24"`
}
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 04432a1..c8feb4c 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -21,6 +21,7 @@ var (
ErrDeleteAlias = errors.New("Unable to delete alias. Please try again.")
ErrDeleteAliasByUserID = errors.New("Unable to delete aliases for this user.")
ErrDeleteAliasByDomain = errors.New("Unable to delete aliases for this domain.")
+ ErrFailedImport = errors.New("Failed to import aliases. Please check the format and try again.")
)
type AliasStore interface {
@@ -225,16 +226,26 @@ func (s *Service) FindAlias(email string) (model.Alias, error) {
func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImportReq, userID string) ([]model.Alias, error) {
var importedAliases []model.Alias
+ domains, err := s.GetDomains(ctx, userID)
+ if err != nil {
+ return nil, ErrFailedImport
+ }
+
for _, req := range aliases {
rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID)
if err != nil || len(rcps) == 0 {
- log.Printf("error importing alias: %s", err.Error())
continue
}
- fqdn, err := s.GetVerifiedDomain(ctx, req.Domain, userID)
- if err != nil {
- log.Printf("error importing alias: %s", err.Error())
+ domainFound := false
+ for _, domain := range domains {
+ if domain.Name == req.Domain {
+ domainFound = true
+ break
+ }
+ }
+
+ if !domainFound {
continue
}
@@ -246,7 +257,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport
FromName: req.FromName,
}
- importedAlias, err := s.PostAlias(ctx, alias, req.Format, fqdn.Name, req.LocalPart)
+ importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart)
if err != nil {
log.Printf("error importing alias: %s", err.Error())
continue
diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go
index 1beed0e..8b9ae75 100644
--- a/api/internal/transport/api/alias.go
+++ b/api/internal/transport/api/alias.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/csv"
"io"
- "log"
"strconv"
"strings"
@@ -197,8 +196,6 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
continue
}
- log.Printf("Parsed alias: %+v\n", req)
-
rows = append(rows, req)
}
From 909d53f26c0a18e6ee334446246dfd4ea91e940e Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 31 Mar 2026 13:45:04 +0200
Subject: [PATCH 04/11] feat(api): update routes.go
---
api/internal/service/alias.go | 1 -
api/internal/transport/api/routes.go | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index c8feb4c..1b87570 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -259,7 +259,6 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport
importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart)
if err != nil {
- log.Printf("error importing alias: %s", err.Error())
continue
}
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index 980d80c..4cd7103 100644
--- a/api/internal/transport/api/routes.go
+++ b/api/internal/transport/api/routes.go
@@ -85,7 +85,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) {
v1.Get("/alias/:id", h.GetAlias)
v1.Get("/aliases", h.GetAliases)
- v1.Get("/aliases/import", h.ImportAliases)
+ v1.Post("/aliases/import", h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
From adf832dab881d659e1829aa1e662ac67d9ac0b11 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 31 Mar 2026 15:55:57 +0200
Subject: [PATCH 05/11] feat(app): create AccountAliasImport.vue
---
app/src/api/alias.ts | 1 +
app/src/components/Account.vue | 3 +
app/src/components/AccountAliasImport.vue | 90 +++++++++++++++++++++++
app/src/style/components/form.css | 4 +
4 files changed, 98 insertions(+)
create mode 100644 app/src/components/AccountAliasImport.vue
diff --git a/app/src/api/alias.ts b/app/src/api/alias.ts
index 564414b..9ab8bea 100644
--- a/app/src/api/alias.ts
+++ b/app/src/api/alias.ts
@@ -3,6 +3,7 @@ import { api } from './api'
export const aliasApi = {
get: (id: string) => api.get('/alias/' + id),
getList: (data: any) => api.get('/aliases', { params: data }),
+ import: (data: any) => api.post('/aliases/import', data),
export: () => api.get('/aliases/export'),
create: (data: any) => api.post('/alias', data),
update: (id: string, data: any) => api.put('/alias/' + id, data),
diff --git a/app/src/components/Account.vue b/app/src/components/Account.vue
index 6be03fa..4f4c2c7 100644
--- a/app/src/components/Account.vue
+++ b/app/src/components/Account.vue
@@ -17,6 +17,8 @@
+
+
@@ -40,5 +42,6 @@ import AccountTotp from './AccountTotp.vue'
import AccountPasskeys from './AccountPasskeys.vue'
import AccountAccessKeys from './AccountAccessKeys.vue'
import AccountAliasExport from './AccountAliasExport.vue'
+import AccountAliasImport from './AccountAliasImport.vue'
import AccountDelete from './AccountDelete.vue'
\ No newline at end of file
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
new file mode 100644
index 0000000..a78f833
--- /dev/null
+++ b/app/src/components/AccountAliasImport.vue
@@ -0,0 +1,90 @@
+
+
+
Alias Import
+
+ Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported.
+
+
+ CSV file format:
+
+
+ alias,description,enabled,recipients
+ some.alias@example.net,A description,true,recipient1@provider.net recipient2@provider.net
+
+
+
+
+
Only .csv files are supported.
+
+
+
+
+
+
Error: {{ error }}
+
Successfully imported {{ success.count }} aliases.
+
0 aliases imported.
+
+
+
+
\ No newline at end of file
diff --git a/app/src/style/components/form.css b/app/src/style/components/form.css
index 4f41e9e..1837faa 100644
--- a/app/src/style/components/form.css
+++ b/app/src/style/components/form.css
@@ -37,6 +37,10 @@
@apply form-radio border-secondary bg-secondary rounded-full text-accent focus:ring-transparent dark:checked:border-transparent dark:focus:ring-offset-transparent;
}
+ input[type=file] {
+ @apply text-sm text-secondary border-primary cursor-pointer focus:outline-none focus:border-accent focus:ring-transparent;
+ }
+
select {
@apply bg-secondary text-secondary border-primary form-select py-2.5 px-4 pe-9 block w-full border focus:border-accent disabled:opacity-50 disabled:pointer-events-none outline-none focus:ring-transparent cursor-pointer mb-2;
From d7731d2f5d05ab8cd586afdc1ef7170b344d4e9f Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 31 Mar 2026 17:52:13 +0200
Subject: [PATCH 06/11] feat(app): update AccountAliasImport.vue
---
app/src/components/AccountAliasImport.vue | 1 -
1 file changed, 1 deletion(-)
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
index a78f833..d64452b 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -14,7 +14,6 @@
From 3636278b5b508e95d88df4652934479fc9b903f0 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Mon, 20 Apr 2026 11:23:40 +0200
Subject: [PATCH 10/11] feat(api): update alias.go
---
api/internal/service/alias.go | 3 ++-
api/internal/transport/api/alias.go | 2 +-
app/src/components/AccountAliasImport.vue | 6 +++++-
3 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index e298f35..839132b 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -22,6 +22,7 @@ var (
ErrDeleteAliasByUserID = errors.New("Unable to delete aliases for this user.")
ErrDeleteAliasByDomain = errors.New("Unable to delete aliases for this domain.")
ErrFailedImport = errors.New("Failed to import aliases. Please check the format and try again.")
+ ErrFailedImportLimit = errors.New("Failed to import aliases. You can only import up to 500 aliases at a time.")
)
type AliasStore interface {
@@ -244,7 +245,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport
}
if len(aliases) > 500 {
- return nil, ErrFailedImport
+ return nil, ErrFailedImportLimit
}
for _, req := range aliases {
diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go
index 8b9ae75..704a3f4 100644
--- a/api/internal/transport/api/alias.go
+++ b/api/internal/transport/api/alias.go
@@ -202,7 +202,7 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
aliases, err := h.Service.ImportAliases(c.Context(), rows, userID)
if err != nil {
return c.Status(400).JSON(fiber.Map{
- "error": ErrFailedImport,
+ "error": err.Error(),
})
}
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
index 0845afe..34c0d47 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -80,7 +80,11 @@ const importAliases = async () => {
error.value = ''
} catch (err) {
if (axios.isAxiosError(err)) {
- error.value = err.message
+ error.value = err.response?.data.error || err.message
+
+ if (err.response?.status === 429) {
+ error.value = 'Too many requests, please try again later.'
+ }
}
} finally {
importing.value = false
From c59a59b358a78eb6e18a20cfff987162978793eb Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 21 Apr 2026 11:34:21 +0200
Subject: [PATCH 11/11] feat(service): update alias.go
---
api/internal/service/alias.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 839132b..2a664a0 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -121,7 +121,7 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin
return model.Alias{}, ErrPostAlias
}
- if count >= s.Cfg.Service.MaxDailyAliases {
+ if count >= s.Cfg.Service.MaxDailyAliases && format != model.AliasFormatCustom {
return model.Alias{}, ErrPostAliasLimit
}