From a7783312ac1617a9f94d9b9d89efe520d5d07f20 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Mon, 30 Mar 2026 17:54:53 +0200
Subject: [PATCH 01/16] 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 1daf5097..f3531cd4 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 b5698bf7..980d80c4 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/16] 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 f7922d51..6c941081 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 d200c811..04432a12 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 f3531cd4..1beed0ee 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/16] 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 6c941081..022b124f 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 04432a12..c8feb4c8 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 1beed0ee..8b9ae75b 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/16] 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 c8feb4c8..1b875709 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 980d80c4..4cd71038 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/16] 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 564414b0..9ab8bead 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 6be03fa4..4f4c2c7d 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 00000000..a78f8339
--- /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.
+
+
+
+ Import Aliases
+
+
+ Importing...
+
+
+
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 4f41e9ee..1837faad 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/16] 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 a78f8339..d64452b8 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -14,7 +14,6 @@
Date: Tue, 31 Mar 2026 18:01:51 +0200
Subject: [PATCH 07/16] feat(app): update AccountAliasImport.vue
---
app/src/components/AccountAliasImport.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
index d64452b8..ebedb611 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -9,7 +9,7 @@
alias,description,enabled,recipients
- some.alias@example.net,A description,true,recipient1@provider.net recipient2@provider.net
+ some.alias@example.net,A description,true,recipient1@example.net recipient2@example.net
From ef143b8492e7b8a99ae7bf8c85e3490799b56abc Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Thu, 16 Apr 2026 12:00:33 +0200
Subject: [PATCH 08/16] feat(api): update routes.go
---
api/internal/service/alias.go | 4 ++++
api/internal/transport/api/routes.go | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 1b875709..79cb8969 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -231,6 +231,10 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport
return nil, ErrFailedImport
}
+ if len(aliases) > 500 {
+ return nil, ErrFailedImport
+ }
+
for _, req := range aliases {
rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID)
if err != nil || len(rcps) == 0 {
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index 4cd71038..78c9d161 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.Post("/aliases/import", h.ImportAliases)
+ v1.Post("/aliases/import", limit.New(5, 60*time.Minute), h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
From dd2f4c89f7b4003e36527b0187c634141559414d Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Thu, 16 Apr 2026 12:18:50 +0200
Subject: [PATCH 09/16] feat(service): update alias.go
---
api/internal/service/alias.go | 12 ++++++++++++
app/src/components/AccountAliasImport.vue | 4 ++--
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 79cb8969..e298f352 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -154,6 +154,18 @@ func (s *Service) PostAlias(ctx context.Context, alias model.Alias, format strin
return alias, nil
}
+ // Custom alias with custom domain
+ if format == model.AliasFormatCustom {
+ alias.Name = model.GenerateAlias(format, localPart) + "@" + domain
+ alias, err = s.Store.PostAlias(ctx, alias)
+ if err != nil {
+ log.Printf("error creating custom alias: %s", err.Error())
+ return model.Alias{}, ErrPostAlias
+ }
+
+ return alias, nil
+ }
+
// Standard alias
for range 5 {
alias.Name = model.GenerateAlias(format, localPart) + "@" + domain
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
index ebedb611..0845afea 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -30,8 +30,8 @@
Error: {{ error }}
- Successfully imported {{ success.count }} aliases.
- 0 aliases imported.
+ Successfully imported {{ success.count }} aliases
+ 0 aliases imported
From 3636278b5b508e95d88df4652934479fc9b903f0 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Mon, 20 Apr 2026 11:23:40 +0200
Subject: [PATCH 10/16] 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 e298f352..839132bc 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 8b9ae75b..704a3f43 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 0845afea..34c0d478 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/16] 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 839132bc..2a664a05 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
}
From 62221147465de4ddf47048f834d040b278ea82fc Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Fri, 28 Aug 2026 13:12:25 +0200
Subject: [PATCH 12/16] feat(repository): update alias.go
---
api/internal/repository/alias.go | 20 ++++++++++++--------
api/internal/service/alias.go | 1 +
api/internal/transport/api/routes.go | 2 +-
3 files changed, 14 insertions(+), 9 deletions(-)
diff --git a/api/internal/repository/alias.go b/api/internal/repository/alias.go
index deea353f..52325e8d 100644
--- a/api/internal/repository/alias.go
+++ b/api/internal/repository/alias.go
@@ -156,6 +156,7 @@ func (d *Database) PostAlias(ctx context.Context, alias model.Alias, maxDaily in
return err
}
+ // Inbound alias hourly limit check
if alias.Origin == model.Inbound {
var hourly int64
if err := tx.Unscoped().Model(&model.Alias{}).
@@ -168,14 +169,17 @@ func (d *Database) PostAlias(ctx context.Context, alias model.Alias, maxDaily in
}
}
- var daily int64
- if err := tx.Unscoped().Model(&model.Alias{}).
- Where("user_id = ? AND created_at > NOW() - INTERVAL 1 DAY", alias.UserID).
- Count(&daily).Error; err != nil {
- return err
- }
- if int(daily) >= maxDaily {
- return model.ErrDailyAliasLimit
+ // Daily alias limit for non-imported aliases check
+ if alias.Origin != model.Import {
+ var daily int64
+ if err := tx.Unscoped().Model(&model.Alias{}).
+ Where("user_id = ? AND created_at > NOW() - INTERVAL 1 DAY", alias.UserID).
+ Count(&daily).Error; err != nil {
+ return err
+ }
+ if int(daily) >= maxDaily {
+ return model.ErrDailyAliasLimit
+ }
}
return tx.Create(&alias).Error
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 070d3433..1ec40a54 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -386,6 +386,7 @@ func (s *Service) ImportAliases(ctx context.Context, aliases []model.AliasImport
Enabled: req.Enabled,
Recipients: model.GetEmails(rcps),
FromName: req.FromName,
+ Origin: model.Import,
}
importedAlias, err := s.PostAlias(ctx, alias, req.Format, req.Domain, req.LocalPart)
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index fd4562b2..2a4de613 100644
--- a/api/internal/transport/api/routes.go
+++ b/api/internal/transport/api/routes.go
@@ -88,7 +88,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) {
v1.Get("/alias/:id", h.GetAlias)
v1.Get("/aliases", h.GetAliases)
- v1.Post("/aliases/import", limit.New(5, 60*time.Minute), h.ImportAliases)
+ v1.Post("/aliases/import", limit.New(5, 24*time.Hour), h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
From 130a14950adda15baa5738742581dacf1346b238 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Fri, 28 Aug 2026 13:32:16 +0200
Subject: [PATCH 13/16] feat(app): update AccountAliasImport.vue
---
app/src/components/AccountAliasImport.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/components/AccountAliasImport.vue b/app/src/components/AccountAliasImport.vue
index 34c0d478..ba92d0bb 100644
--- a/app/src/components/AccountAliasImport.vue
+++ b/app/src/components/AccountAliasImport.vue
@@ -2,7 +2,7 @@
Alias Import
- Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported.
+ Import a list of your aliases from a CSV file. Only aliases with your verified domain will be imported. Import is limited to 500 aliases per file.
CSV file format:
From a91cba7ce14e84d6f91a755365bbbb74a59ef983 Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Fri, 11 Sep 2026 12:00:55 +0200
Subject: [PATCH 14/16] chore: update routes.go
---
api/internal/transport/api/routes.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index 2a4de613..c7c4b42b 100644
--- a/api/internal/transport/api/routes.go
+++ b/api/internal/transport/api/routes.go
@@ -88,7 +88,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) {
v1.Get("/alias/:id", h.GetAlias)
v1.Get("/aliases", h.GetAliases)
- v1.Post("/aliases/import", limit.New(5, 24*time.Hour), h.ImportAliases)
+ v1.Post("/aliases/import", h.ImportAliases) // disabled `limit.New(5, 24*time.Hour)` for QA
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
From b33620d770a270098e3774e716f6fbf4ac7c650f Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Fri, 11 Sep 2026 15:31:29 +0200
Subject: [PATCH 15/16] feat(service): update alias.go
---
api/internal/service/alias.go | 10 +++++++
api/internal/service/alias_test.go | 38 ++++++++++++++++++++++++++
api/internal/service/recipient_test.go | 10 +++++++
api/internal/transport/api/alias.go | 7 +++++
4 files changed, 65 insertions(+)
diff --git a/api/internal/service/alias.go b/api/internal/service/alias.go
index 1ec40a54..674b4818 100644
--- a/api/internal/service/alias.go
+++ b/api/internal/service/alias.go
@@ -353,6 +353,16 @@ 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
+ sub, err := s.GetSubscription(ctx, userID)
+ if err != nil {
+ log.Printf("error fetching subscription: %s", err.Error())
+ return nil, ErrPostAlias
+ }
+
+ if !sub.ActiveStatus() {
+ return nil, ErrPostAliasInactiveSub
+ }
+
domains, err := s.GetDomains(ctx, userID)
if err != nil {
return nil, ErrFailedImport
diff --git a/api/internal/service/alias_test.go b/api/internal/service/alias_test.go
index d1f43f25..632014c5 100644
--- a/api/internal/service/alias_test.go
+++ b/api/internal/service/alias_test.go
@@ -210,3 +210,41 @@ func TestPostAlias_CustomDomainSucceeds(t *testing.T) {
t.Errorf("expected alias name newalias@customdomain.com, got %s", alias.Name)
}
}
+
+func TestImportAliases_InactiveSubscriptionFailsFast(t *testing.T) {
+ store := newFakeStore()
+ store.subscription = model.Subscription{ActiveUntil: time.Now().Add(-time.Hour)}
+ s := newTestService(store)
+
+ reqs := []model.AliasImportReq{
+ {Domain: "customdomain.com", LocalPart: "newalias", Recipients: "rcpt@example.com", Format: model.AliasFormatCustom},
+ }
+
+ aliases, err := s.ImportAliases(context.Background(), reqs, "user-1")
+ if !errors.Is(err, ErrPostAliasInactiveSub) {
+ t.Errorf("expected error %v, got %v", ErrPostAliasInactiveSub, err)
+ }
+ if len(aliases) != 0 {
+ t.Errorf("expected no aliases imported, got %d", len(aliases))
+ }
+}
+
+func TestImportAliases_ActiveSubscriptionImportsValidRows(t *testing.T) {
+ store := newFakeStore()
+ store.subscription = model.Subscription{ActiveUntil: time.Now().Add(time.Hour)}
+ store.domains["customdomain.com"] = model.Domain{Name: "customdomain.com", UserID: "user-1", Enabled: true}
+ store.verifiedRecipients["user-1"] = []model.Recipient{{Email: "rcpt@example.com"}}
+ s := newTestService(store)
+
+ reqs := []model.AliasImportReq{
+ {Domain: "customdomain.com", LocalPart: "newalias", Recipients: "rcpt@example.com", Format: model.AliasFormatCustom},
+ }
+
+ aliases, err := s.ImportAliases(context.Background(), reqs, "user-1")
+ if err != nil {
+ t.Fatalf("expected no error, got %v", err)
+ }
+ if len(aliases) != 1 || aliases[0].Name != "newalias@customdomain.com" {
+ t.Errorf("expected 1 imported alias newalias@customdomain.com, got %+v", aliases)
+ }
+}
diff --git a/api/internal/service/recipient_test.go b/api/internal/service/recipient_test.go
index 17166242..2605615d 100644
--- a/api/internal/service/recipient_test.go
+++ b/api/internal/service/recipient_test.go
@@ -54,6 +54,16 @@ func (f *fakeStore) GetVerifiedDomainByName(ctx context.Context, name string) (m
return domain, nil
}
+func (f *fakeStore) GetDomains(ctx context.Context, userID string) ([]model.Domain, error) {
+ var result []model.Domain
+ for _, d := range f.domains {
+ if d.UserID == userID {
+ result = append(result, d)
+ }
+ }
+ return result, nil
+}
+
func (f *fakeStore) GetSettings(ctx context.Context, userID string) (model.Settings, error) {
return f.settingsByUser[userID], nil
}
diff --git a/api/internal/transport/api/alias.go b/api/internal/transport/api/alias.go
index 62d56b62..ec176167 100644
--- a/api/internal/transport/api/alias.go
+++ b/api/internal/transport/api/alias.go
@@ -187,6 +187,13 @@ func (h *Handler) ImportAliases(c *fiber.Ctx) error {
})
}
+ // A row with a missing column would otherwise panic on the index access below
+ if len(record) < 4 {
+ return c.Status(400).JSON(fiber.Map{
+ "error": ErrFailedImport,
+ })
+ }
+
// record[0] = alias, record[1] = description, record[2] = enabled, record[3] = recipients
fullAlias := record[0]
parts := strings.Split(fullAlias, "@")
From c3f3e3df5ed88b873067f980d9256e2c34516f9e Mon Sep 17 00:00:00 2001
From: Juraj Hilje
Date: Tue, 15 Sep 2026 09:21:24 +0200
Subject: [PATCH 16/16] chore(api): update routes.go
---
api/internal/transport/api/routes.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go
index c7c4b42b..2a4de613 100644
--- a/api/internal/transport/api/routes.go
+++ b/api/internal/transport/api/routes.go
@@ -88,7 +88,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) {
v1.Get("/alias/:id", h.GetAlias)
v1.Get("/aliases", h.GetAliases)
- v1.Post("/aliases/import", h.ImportAliases) // disabled `limit.New(5, 24*time.Hour)` for QA
+ v1.Post("/aliases/import", limit.New(5, 24*time.Hour), h.ImportAliases)
v1.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)