Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a778331
feat(api): update alias.go
jurajhilje Mar 30, 2026
087ade1
feat(service): update alias.go
jurajhilje Mar 31, 2026
89159ac
feat(service): update alias.go
jurajhilje Mar 31, 2026
909d53f
feat(api): update routes.go
jurajhilje Mar 31, 2026
adf832d
feat(app): create AccountAliasImport.vue
jurajhilje Mar 31, 2026
5f1c3f4
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Mar 31, 2026
d7731d2
feat(app): update AccountAliasImport.vue
jurajhilje Mar 31, 2026
94a5696
feat(app): update AccountAliasImport.vue
jurajhilje Mar 31, 2026
479e571
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Apr 15, 2026
ef143b8
feat(api): update routes.go
jurajhilje Apr 16, 2026
dd2f4c8
feat(service): update alias.go
jurajhilje Apr 16, 2026
3636278
feat(api): update alias.go
jurajhilje Apr 20, 2026
c59a59b
feat(service): update alias.go
jurajhilje Apr 21, 2026
a0767e6
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 11, 2026
ce3ea5c
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 21, 2026
450c2d0
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje May 22, 2026
9d1e634
Merge branch 'feature/custom-alias' into feature/alias-import
jurajhilje Jun 8, 2026
730afaa
Merge branch 'main' into feature/alias-import
jurajhilje Jul 6, 2026
ff2bae0
Merge branch 'main' into feature/alias-import
jurajhilje Jul 6, 2026
7a33985
Merge branch 'main' into feature/alias-import
jurajhilje Jul 14, 2026
b576038
Merge branch 'main' into feature/alias-import
jurajhilje Jul 15, 2026
391c163
Merge branch 'main' into feature/alias-import
jurajhilje Jul 21, 2026
7eaeca6
Merge branch 'main' into feature/alias-import
jurajhilje Jul 23, 2026
b1cf9ef
Merge branch 'main' into feature/alias-import
jurajhilje Aug 13, 2026
518b26b
Merge branch 'main' into feature/alias-import
jurajhilje Aug 25, 2026
c562328
Merge branch 'main' into feature/alias-import
jurajhilje Aug 26, 2026
37b1a62
Merge branch 'main' into feature/alias-import
jurajhilje Aug 28, 2026
6222114
feat(repository): update alias.go
jurajhilje Aug 28, 2026
130a149
feat(app): update AccountAliasImport.vue
jurajhilje Aug 28, 2026
b292409
Merge branch 'main' into feature/alias-import
jurajhilje Aug 31, 2026
9788629
Merge branch 'main' into feature/alias-import
jurajhilje Sep 7, 2026
94c8ff9
Merge branch 'main' into feature/alias-import
jurajhilje Sep 8, 2026
d9dbb6b
Merge branch 'main' into feature/alias-import
jurajhilje Sep 9, 2026
a91cba7
chore: update routes.go
jurajhilje Sep 11, 2026
b33620d
feat(service): update alias.go
jurajhilje Sep 11, 2026
c3f3e3d
chore(api): update routes.go
jurajhilje Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api/internal/model/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,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,min=6,max=24"`
}
20 changes: 12 additions & 8 deletions api/internal/repository/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}).
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions api/internal/service/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ 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.")
ErrFailedImportLimit = errors.New("Failed to import aliases. You can only import up to 500 aliases at a time.")
)

type AliasStore interface {
Expand Down Expand Up @@ -348,6 +350,66 @@ 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

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
}

if len(aliases) > 500 {
return nil, ErrFailedImportLimit
}

for _, req := range aliases {
rcps, err := s.GetVerifiedRecipients(ctx, req.Recipients, userID)
if err != nil || len(rcps) == 0 {
continue
}

domainFound := false
for _, domain := range domains {
if domain.Name == req.Domain {
domainFound = true
break
}
}

if !domainFound {
continue
}

alias := model.Alias{
UserID: userID,
Description: req.Description,
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)
if err != nil {
continue
}

importedAliases = append(importedAliases, importedAlias)
}

return importedAliases, nil
}

func (s *Service) RestoreAlias(ctx context.Context, ID string, userID string) error {
err := s.Store.RestoreAlias(ctx, ID, userID)
if err != nil {
Expand Down
38 changes: 38 additions & 0 deletions api/internal/service/alias_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 10 additions & 0 deletions api/internal/service/recipient_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
97 changes: 97 additions & 0 deletions api/internal/transport/api/alias.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"context"
"encoding/csv"
"io"
"strconv"
"strings"

Expand All @@ -17,6 +19,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."
RestoreAliasSuccess = "Alias restored successfully."
)

Expand All @@ -27,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)
RestoreAlias(context.Context, string, string) error
}

Expand Down Expand Up @@ -138,6 +143,98 @@ 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": ErrFailedImport,
})
}

f, err := file.Open()
if err != nil {
return c.Status(400).JSON(fiber.Map{
"error": ErrFailedImport,
})
}
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": ErrFailedImport,
})
}

var rows []model.AliasImportReq

// Iterate through rows
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return c.Status(400).JSON(fiber.Map{
"error": ErrFailedImport,
})
}

// 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, "@")

var local, domain string
if len(parts) == 2 {
local = parts[0]
domain = parts[1]
}

req := model.AliasImportReq{
Description: record[1],
Enabled: strings.ToLower(record[2]) == "true",
Recipients: strings.ReplaceAll(record[3], " ", ","),
LocalPart: local,
Domain: domain,
Format: model.AliasFormatCustom,
}

// Validate alias row
err = h.Validator.Struct(req)
if err != nil {
continue
}

rows = append(rows, req)
}

aliases, err := h.Service.ImportAliases(c.Context(), rows, userID)
if err != nil {
return c.Status(400).JSON(fiber.Map{
"error": err.Error(),
})
}

return c.JSON(fiber.Map{
"message": AliasImportSuccess,
"count": len(aliases),
})
}

// @Summary Export aliases
// @Description Export all aliases as CSV
// @Tags alias
Expand Down
1 change: 1 addition & 0 deletions api/internal/transport/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +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.Get("/aliases/export", h.ExportAliases)
v1.Post("/alias", limiter.New(), h.PostAlias)
v1.Put("/alias/:id", h.UpdateAlias)
Expand Down
1 change: 1 addition & 0 deletions app/src/api/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions app/src/components/Account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
<hr>
<AccountAccessKeys />
<hr>
<AccountAliasImport />
<hr>
<AccountAliasExport />
<hr>
<AccountDelete />
Expand All @@ -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'
</script>
Loading
Loading