From 17111e7098462288ffca4546de948df079d56dde Mon Sep 17 00:00:00 2001
From: mqmalagris <63169613+mqmalagris@users.noreply.github.com>
Date: Sat, 25 Apr 2026 21:19:10 -0300
Subject: [PATCH 1/3] feat: implement account and domain deletion functionality
with confirmation prompts
---
apps/web/src/components/AccountView.svelte | 43 +++++++++---
apps/web/src/components/DomainManager.svelte | 17 +++++
packages/shared/src/api-client.ts | 14 ++++
packages/shared/src/i18n/locales/en.ts | 6 ++
packages/shared/src/i18n/locales/es.ts | 6 ++
packages/shared/src/i18n/locales/fr.ts | 6 ++
packages/shared/src/i18n/locales/pt-BR.ts | 6 ++
packages/shared/src/types.ts | 1 +
services/internal/domain/repository.go | 12 ++++
services/internal/domain/service.go | 22 ++++++
services/internal/handler/api.go | 73 ++++++++++++++++++++
services/internal/user/repository.go | 12 ++++
terraform/api_gateway.tf | 12 ++++
13 files changed, 219 insertions(+), 11 deletions(-)
diff --git a/apps/web/src/components/AccountView.svelte b/apps/web/src/components/AccountView.svelte
index bbed7f9..deee4b6 100644
--- a/apps/web/src/components/AccountView.svelte
+++ b/apps/web/src/components/AccountView.svelte
@@ -102,19 +102,17 @@
}
}
- onMount(async () => {
- initLocale();
- await initUser();
- loadInboxes();
-
- function onVisibilityChange() {
- if (document.visibilityState === "visible") {
- refreshUser();
- loadInboxes();
- }
+ function onVisibilityChange() {
+ if (document.visibilityState === "visible") {
+ refreshUser();
+ loadInboxes();
}
- document.addEventListener("visibilitychange", onVisibilityChange);
+ }
+ onMount(() => {
+ initLocale();
+ initUser().then(() => loadInboxes());
+ document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
});
@@ -347,6 +345,29 @@
{/if}
+
+
+
+
{t("dangerZone")}
+ {#if user.isPremium}
+
{t("cancelSubscriptionFirst")}
+ {/if}
+
+
{/if}
diff --git a/apps/web/src/components/DomainManager.svelte b/apps/web/src/components/DomainManager.svelte
index d970d07..0b32964 100644
--- a/apps/web/src/components/DomainManager.svelte
+++ b/apps/web/src/components/DomainManager.svelte
@@ -52,6 +52,17 @@
}
}
+ async function deleteDomain(domainName: string) {
+ if (!user.apiKey) return;
+ if (!confirm(t("deleteDomainConfirm", { domain: domainName }))) return;
+ try {
+ await api.deleteDomain(domainName, user.apiKey);
+ domains = domains.filter((d) => d.domain !== domainName);
+ } catch {
+ // silent
+ }
+ }
+
async function checkDomain(domainName: string) {
if (!user.apiKey) return;
try {
@@ -124,6 +135,12 @@
{t("checkVerification")}
{/if}
+
diff --git a/packages/shared/src/api-client.ts b/packages/shared/src/api-client.ts
index ffcb624..8478d54 100644
--- a/packages/shared/src/api-client.ts
+++ b/packages/shared/src/api-client.ts
@@ -97,5 +97,19 @@ export function createApiClient(baseUrl: string) {
undefined,
userApiKey
),
+
+ deleteDomain: async (domain: string, userApiKey: string): Promise => {
+ await fetch(`${baseUrl}/domains/${domain}`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${userApiKey}` },
+ });
+ },
+
+ deleteAccount: async (userApiKey: string): Promise => {
+ await fetch(`${baseUrl}/user/me`, {
+ method: "DELETE",
+ headers: { Authorization: `Bearer ${userApiKey}` },
+ });
+ },
};
}
diff --git a/packages/shared/src/i18n/locales/en.ts b/packages/shared/src/i18n/locales/en.ts
index dc1b71e..adafa78 100644
--- a/packages/shared/src/i18n/locales/en.ts
+++ b/packages/shared/src/i18n/locales/en.ts
@@ -145,4 +145,10 @@ export const en: Record = {
forwardSaved: "Forwarding saved",
forwardRemoved: "Forwarding removed",
forwardPlaceholder: "you@email.com",
+ deleteAccount: "Delete account",
+ deleteAccountConfirm: "Are you sure? This cannot be undone. All your inboxes, domains, and data will be deleted.",
+ deleteDomain: "Delete",
+ deleteDomainConfirm: "Delete domain {{domain}}? Inboxes on this domain will also be deleted.",
+ dangerZone: "Danger zone",
+ cancelSubscriptionFirst: "Cancel your subscription before deleting your account.",
};
diff --git a/packages/shared/src/i18n/locales/es.ts b/packages/shared/src/i18n/locales/es.ts
index f326695..ba2a157 100644
--- a/packages/shared/src/i18n/locales/es.ts
+++ b/packages/shared/src/i18n/locales/es.ts
@@ -145,4 +145,10 @@ export const es: Record = {
forwardSaved: "Reenvío guardado",
forwardRemoved: "Reenvío eliminado",
forwardPlaceholder: "tu@email.com",
+ deleteAccount: "Eliminar cuenta",
+ deleteAccountConfirm: "¿Estás seguro? Esta acción no se puede deshacer. Todos tus inboxes, dominios y datos serán eliminados.",
+ deleteDomain: "Eliminar",
+ deleteDomainConfirm: "¿Eliminar el dominio {{domain}}? Los inboxes en este dominio también serán eliminados.",
+ dangerZone: "Zona de peligro",
+ cancelSubscriptionFirst: "Cancela tu suscripción antes de eliminar la cuenta.",
};
diff --git a/packages/shared/src/i18n/locales/fr.ts b/packages/shared/src/i18n/locales/fr.ts
index c9bbe7c..29b2d07 100644
--- a/packages/shared/src/i18n/locales/fr.ts
+++ b/packages/shared/src/i18n/locales/fr.ts
@@ -145,4 +145,10 @@ export const fr: Record = {
forwardSaved: "Transfert enregistré",
forwardRemoved: "Transfert supprimé",
forwardPlaceholder: "vous@email.com",
+ deleteAccount: "Supprimer le compte",
+ deleteAccountConfirm: "Êtes-vous sûr ? Cette action est irréversible. Toutes vos boîtes, domaines et données seront supprimés.",
+ deleteDomain: "Supprimer",
+ deleteDomainConfirm: "Supprimer le domaine {{domain}} ? Les boîtes sur ce domaine seront également supprimées.",
+ dangerZone: "Zone de danger",
+ cancelSubscriptionFirst: "Annulez votre abonnement avant de supprimer votre compte.",
};
diff --git a/packages/shared/src/i18n/locales/pt-BR.ts b/packages/shared/src/i18n/locales/pt-BR.ts
index 703085b..b900470 100644
--- a/packages/shared/src/i18n/locales/pt-BR.ts
+++ b/packages/shared/src/i18n/locales/pt-BR.ts
@@ -143,6 +143,12 @@ export const ptBR = {
forwardSaved: "Encaminhamento salvo",
forwardRemoved: "Encaminhamento removido",
forwardPlaceholder: "seu@email.com",
+ deleteAccount: "Excluir conta",
+ deleteAccountConfirm: "Tem certeza? Esta ação não pode ser desfeita. Todos os seus inboxes, domínios e dados serão excluídos.",
+ deleteDomain: "Excluir",
+ deleteDomainConfirm: "Excluir o domínio {{domain}}? Inboxes neste domínio também serão excluídos.",
+ dangerZone: "Zona de perigo",
+ cancelSubscriptionFirst: "Cancele sua assinatura antes de excluir a conta.",
} as const;
export type TranslationKey = keyof typeof ptBR;
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index c3231ed..64bcf8d 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -34,6 +34,7 @@ export interface RegisterResponse {
export interface UserInfo {
user_id: string;
+ email?: string;
tier: string;
role?: string;
no_ads: boolean;
diff --git a/services/internal/domain/repository.go b/services/internal/domain/repository.go
index 0b0c85f..2fa5fc2 100644
--- a/services/internal/domain/repository.go
+++ b/services/internal/domain/repository.go
@@ -57,6 +57,18 @@ func (r *Repository) GetDomain(ctx context.Context, domainName string) (*DomainR
return &record, err
}
+// DeleteDomain removes a domain record.
+func (r *Repository) DeleteDomain(ctx context.Context, domainName string) error {
+ _, err := r.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
+ TableName: &r.tableName,
+ Key: map[string]types.AttributeValue{
+ "inbox_address": &types.AttributeValueMemberS{Value: DomainPKPrefix + domainName},
+ "message_id": &types.AttributeValueMemberS{Value: DomainSentinel},
+ },
+ })
+ return err
+}
+
// UpdateVerificationStatus updates the verification status of a domain.
func (r *Repository) UpdateVerificationStatus(ctx context.Context, domainName, status string) error {
_, err := r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{
diff --git a/services/internal/domain/service.go b/services/internal/domain/service.go
index bf2aa9e..2dca38e 100644
--- a/services/internal/domain/service.go
+++ b/services/internal/domain/service.go
@@ -146,6 +146,28 @@ func (s *Service) ListUserDomains(ctx context.Context, userID string) ([]DomainR
return domains, nil
}
+// DeleteDomain removes a domain from DynamoDB and SES.
+func (s *Service) DeleteDomain(ctx context.Context, userID, domainName string) error {
+ record, err := s.repo.GetDomain(ctx, domainName)
+ if err != nil {
+ return err
+ }
+ if record == nil {
+ return ErrDomainNotFound
+ }
+ if record.UserID != userID {
+ return ErrNotOwner
+ }
+
+ // Remove from SES (best effort)
+ _, _ = s.sesClient.DeleteIdentity(ctx, &ses.DeleteIdentityInput{
+ Identity: &domainName,
+ })
+
+ // Remove from DynamoDB
+ return s.repo.DeleteDomain(ctx, domainName)
+}
+
// IsVerifiedDomain checks if a domain is verified and belongs to the user.
func (s *Service) IsVerifiedDomain(ctx context.Context, userID, domainName string) (bool, error) {
record, err := s.repo.GetDomain(ctx, domainName)
diff --git a/services/internal/handler/api.go b/services/internal/handler/api.go
index d2ab165..fd4a959 100644
--- a/services/internal/handler/api.go
+++ b/services/internal/handler/api.go
@@ -86,6 +86,8 @@ func (a *API) routeInternal(method string, parts []string) http.HandlerFunc {
return a.GetUserMe
case method == "GET" && len(parts) == 2 && parts[0] == "user" && parts[1] == "inboxes":
return a.ListUserInboxes
+ case method == "DELETE" && len(parts) == 2 && parts[0] == "user" && parts[1] == "me":
+ return a.DeleteAccount
// Domain routes
case method == "POST" && len(parts) == 1 && parts[0] == "domains":
@@ -94,6 +96,8 @@ func (a *API) routeInternal(method string, parts []string) http.HandlerFunc {
return a.ListDomains
case method == "GET" && len(parts) == 3 && parts[0] == "domains" && parts[2] == "verify":
return a.VerifyDomain
+ case method == "DELETE" && len(parts) == 2 && parts[0] == "domains":
+ return a.DeleteDomain
// Admin routes
case method == "POST" && len(parts) == 2 && parts[0] == "admin" && parts[1] == "inbox":
@@ -643,6 +647,75 @@ func (a *API) ListDomains(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"domains": domains})
}
+func (a *API) DeleteDomain(w http.ResponseWriter, r *http.Request) {
+ u, err := a.authenticateUser(r)
+ if err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+ if a.domainSvc == nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "domain service not available"})
+ return
+ }
+
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ domainName := parts[1]
+
+ if err := a.domainSvc.DeleteDomain(r.Context(), u.UserID, domainName); err != nil {
+ if errors.Is(err, domain.ErrDomainNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "domain not found"})
+ return
+ }
+ if errors.Is(err, domain.ErrNotOwner) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "not your domain"})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete domain"})
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (a *API) DeleteAccount(w http.ResponseWriter, r *http.Request) {
+ u, err := a.authenticateUser(r)
+ if err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
+ return
+ }
+
+ // Block deletion if user has an active subscription
+ if u.Tier == user.TierPremium {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "cancel subscription first"})
+ return
+ }
+
+ // Delete all user's inboxes
+ addresses, err := a.userSvc.ListInboxes(r.Context(), u.UserID)
+ if err == nil {
+ for _, addr := range addresses {
+ _ = a.inboxSvc.DeleteInbox(r.Context(), addr)
+ }
+ }
+
+ // Delete all user's domains
+ if a.domainSvc != nil {
+ domains, err := a.domainSvc.ListUserDomains(r.Context(), u.UserID)
+ if err == nil {
+ for _, d := range domains {
+ _ = a.domainSvc.DeleteDomain(r.Context(), u.UserID, d.Domain)
+ }
+ }
+ }
+
+ // Delete the user record
+ if err := a.userSvc.DeleteAccount(r.Context(), u.UserID); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete account"})
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (a *API) VerifyDomain(w http.ResponseWriter, r *http.Request) {
u, err := a.authenticateUser(r)
if err != nil {
diff --git a/services/internal/user/repository.go b/services/internal/user/repository.go
index a2e01d8..b818349 100644
--- a/services/internal/user/repository.go
+++ b/services/internal/user/repository.go
@@ -204,6 +204,18 @@ func (r *Repository) GetInboxExpiresAt(ctx context.Context, address string) (int
return 0, nil
}
+// DeleteUser removes a user record.
+func (r *Repository) DeleteUser(ctx context.Context, userID string) error {
+ _, err := r.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{
+ TableName: &r.tableName,
+ Key: map[string]types.AttributeValue{
+ "inbox_address": &types.AttributeValueMemberS{Value: UserPKPrefix + userID},
+ "message_id": &types.AttributeValueMemberS{Value: UserSentinel},
+ },
+ })
+ return err
+}
+
// --- Magic Token operations ---
// CreateMagicToken stores a magic link token.
diff --git a/terraform/api_gateway.tf b/terraform/api_gateway.tf
index eee7bbf..ace3341 100644
--- a/terraform/api_gateway.tf
+++ b/terraform/api_gateway.tf
@@ -133,6 +133,18 @@ resource "aws_apigatewayv2_route" "get_domain_verify" {
target = "integrations/${aws_apigatewayv2_integration.api.id}"
}
+resource "aws_apigatewayv2_route" "delete_domain" {
+ api_id = aws_apigatewayv2_api.main.id
+ route_key = "DELETE /domains/{domain}"
+ target = "integrations/${aws_apigatewayv2_integration.api.id}"
+}
+
+resource "aws_apigatewayv2_route" "delete_user_me" {
+ api_id = aws_apigatewayv2_api.main.id
+ route_key = "DELETE /user/me"
+ target = "integrations/${aws_apigatewayv2_integration.api.id}"
+}
+
resource "aws_apigatewayv2_route" "post_auth_magic" {
api_id = aws_apigatewayv2_api.main.id
route_key = "POST /auth/magic"
From 010e936e94e063698121327e10b2e1994df1d207 Mon Sep 17 00:00:00 2001
From: mqmalagris <63169613+mqmalagris@users.noreply.github.com>
Date: Sat, 25 Apr 2026 21:19:18 -0300
Subject: [PATCH 2/3] feat: add DeleteAccount function to remove user records
---
services/internal/user/service.go | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/services/internal/user/service.go b/services/internal/user/service.go
index 1b547a3..46a0a29 100644
--- a/services/internal/user/service.go
+++ b/services/internal/user/service.go
@@ -295,6 +295,11 @@ func (s *Service) ListInboxes(ctx context.Context, userID string) ([]string, err
return active, nil
}
+// DeleteAccount removes the user record. Inboxes and domains are deleted by callers.
+func (s *Service) DeleteAccount(ctx context.Context, userID string) error {
+ return s.repo.DeleteUser(ctx, userID)
+}
+
// SetTier updates a user's tier (for admin/webhook use).
func (s *Service) SetTier(ctx context.Context, userID, tier string) error {
noAds := tier == TierPremium
From 95d2cfd00e5681809f752c0f1708198ba34bf418 Mon Sep 17 00:00:00 2001
From: mqmalagris <63169613+mqmalagris@users.noreply.github.com>
Date: Sat, 25 Apr 2026 21:43:50 -0300
Subject: [PATCH 3/3] feat: add URL-based locale routing for multilingual SEO
Configure Astro i18n with /pt-br/, /es/, /fr/ prefixes and fallback rewrites
so each locale gets its own indexable URL with proper hreflang alternates.
---
apps/web/astro.config.mjs | 59 ++++++++++-
apps/web/src/app.d.ts | 6 ++
apps/web/src/components/AccountView.svelte | 10 +-
apps/web/src/components/AdminView.svelte | 8 +-
apps/web/src/components/InboxView.svelte | 6 +-
apps/web/src/components/LandingPage.svelte | 15 +--
apps/web/src/components/LocaleToggle.svelte | 9 +-
apps/web/src/components/LoginView.svelte | 6 +-
apps/web/src/layouts/Layout.astro | 102 ++++++++++++++++----
apps/web/src/lib/i18n.svelte.ts | 73 +++++++++-----
apps/web/src/middleware.ts | 16 +++
apps/web/src/pages/auth/verify.astro | 16 ++-
apps/web/src/pages/docs.astro | 10 +-
apps/web/src/pages/inbox/[address].astro | 5 +-
apps/web/src/pages/inbox/index.astro | 5 +-
apps/web/src/pages/privacy.astro | 5 +-
apps/web/src/pages/terms.astro | 8 +-
17 files changed, 272 insertions(+), 87 deletions(-)
create mode 100644 apps/web/src/middleware.ts
diff --git a/apps/web/astro.config.mjs b/apps/web/astro.config.mjs
index b84a4bf..af1fcd3 100644
--- a/apps/web/astro.config.mjs
+++ b/apps/web/astro.config.mjs
@@ -9,5 +9,62 @@ export default defineConfig({
site: "https://ephemask.com",
output: "server",
adapter: vercel(),
- integrations: [svelte(), react(), tailwind(), sitemap()],
+ i18n: {
+ defaultLocale: "en",
+ locales: [
+ "en",
+ { path: "pt-br", codes: ["pt-BR", "pt"] },
+ "es",
+ "fr",
+ ],
+ routing: {
+ prefixDefaultLocale: false,
+ fallbackType: "rewrite",
+ },
+ fallback: {
+ "pt-br": "en",
+ es: "en",
+ fr: "en",
+ },
+ },
+ integrations: [
+ svelte(),
+ react(),
+ tailwind(),
+ sitemap({
+ i18n: {
+ defaultLocale: "en",
+ locales: {
+ en: "en",
+ "pt-br": "pt-BR",
+ es: "es",
+ fr: "fr",
+ },
+ },
+ filter: (page) =>
+ !page.includes("/admin") &&
+ !page.includes("/auth/") &&
+ !page.includes("/inbox"),
+ customPages: [
+ "https://ephemask.com/pt-br/",
+ "https://ephemask.com/pt-br/account/",
+ "https://ephemask.com/pt-br/login/",
+ "https://ephemask.com/pt-br/docs/",
+ "https://ephemask.com/pt-br/privacy/",
+ "https://ephemask.com/pt-br/terms/",
+ "https://ephemask.com/es/",
+ "https://ephemask.com/es/account/",
+ "https://ephemask.com/es/login/",
+ "https://ephemask.com/es/docs/",
+ "https://ephemask.com/es/privacy/",
+ "https://ephemask.com/es/terms/",
+ "https://ephemask.com/fr/",
+ "https://ephemask.com/fr/account/",
+ "https://ephemask.com/fr/login/",
+ "https://ephemask.com/fr/docs/",
+ "https://ephemask.com/fr/privacy/",
+ "https://ephemask.com/fr/terms/",
+ ],
+ }),
+ ],
});
diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts
index d95cd9e..040c0ac 100644
--- a/apps/web/src/app.d.ts
+++ b/apps/web/src/app.d.ts
@@ -1,5 +1,11 @@
///
+declare namespace App {
+ interface Locals {
+ locale: import("@ephemask/shared").Locale;
+ }
+}
+
declare namespace svelteHTML {
interface HTMLAttributes {
onclick?: (event: MouseEvent) => void;
diff --git a/apps/web/src/components/AccountView.svelte b/apps/web/src/components/AccountView.svelte
index deee4b6..8762dfa 100644
--- a/apps/web/src/components/AccountView.svelte
+++ b/apps/web/src/components/AccountView.svelte
@@ -1,7 +1,7 @@
diff --git a/apps/web/src/components/LoginView.svelte b/apps/web/src/components/LoginView.svelte
index 88985cb..deb2c8e 100644
--- a/apps/web/src/components/LoginView.svelte
+++ b/apps/web/src/components/LoginView.svelte
@@ -1,7 +1,7 @@
+
-
+