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 @@ + - +