From 2149c29a32545aa720ea624e68e0507d28df05f9 Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 01:23:48 +0100 Subject: [PATCH] =?UTF-8?q?=D0=AF=D1=89=D0=B8=D0=BA=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=B2=D0=B5=D0=B1-=D0=BF=D0=BE=D1=87=D1=82=D1=8B:=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D1=91=D1=82=20no-?= =?UTF-8?q?reply@,=20monitor@,=20support@,=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=91=D1=82=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D1=8C,=20=D0=B2=D1=8B=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Регистрация остаётся закрытой. Администраторы — MAIL_ADMINS (по умолчанию первый ящик); роль не в базе, укравший ящик себя админом не сделает. API /api/admin/mailboxes, сгенерированный пароль — один раз в ответе, в лог не пишется. Настройки → MAILBOXES (видно только администратору). Тесты: MailboxAdminControllerTest (5), всего 73. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../com/mailserver/config/MailAdmins.java | 36 ++++ .../controller/AuthenticationController.java | 8 +- .../controller/MailboxAdminController.java | 165 ++++++++++++++++++ .../com/mailserver/dto/ProfileResponse.java | 4 +- .../com/mailserver/service/UserService.java | 19 ++ .../src/main/resources/application.properties | 2 + .../AuthenticationControllerTest.java | 2 +- .../MailboxAdminControllerTest.java | 129 ++++++++++++++ frontend/src/app/core/api.service.ts | 14 +- frontend/src/app/core/models.ts | 19 ++ .../app/pages/settings/mailboxes.section.ts | 124 +++++++++++++ .../src/app/pages/settings/settings.page.ts | 7 +- 13 files changed, 524 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/config/MailAdmins.java create mode 100644 backend/src/main/java/com/mailserver/controller/MailboxAdminController.java create mode 100644 backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java create mode 100644 frontend/src/app/pages/settings/mailboxes.section.ts diff --git a/README.md b/README.md index 716a207..d9c3fc3 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` | +| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) | | `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) | | `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | diff --git a/backend/src/main/java/com/mailserver/config/MailAdmins.java b/backend/src/main/java/com/mailserver/config/MailAdmins.java new file mode 100644 index 0000000..2c48d5a --- /dev/null +++ b/backend/src/main/java/com/mailserver/config/MailAdmins.java @@ -0,0 +1,36 @@ +package com.mailserver.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first + * mailbox — {@code mailserver.admin.email}, or admin@<first domain>. No role in the database: the list lives in + * the configuration, so a stolen mailbox can not make itself an administrator. + */ +@Component +public class MailAdmins { + + private final Set admins; + + public MailAdmins(@Value("${mailserver.admins:}") String admins, + @Value("${mailserver.admin.email:}") String firstMailbox, + MailDomains domains) { + String list = !admins.isBlank() ? admins + : !firstMailbox.isBlank() ? firstMailbox + : "admin@" + domains.domains().iterator().next(); + this.admins = Arrays.stream(list.split(",")) + .map(a -> a.trim().toLowerCase(Locale.ROOT)) + .filter(a -> !a.isEmpty()) + .collect(Collectors.toUnmodifiableSet()); + } + + public boolean isAdmin(String email) { + return email != null && admins.contains(email.toLowerCase(Locale.ROOT)); + } +} diff --git a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java index d1f495e..9de1c61 100644 --- a/backend/src/main/java/com/mailserver/controller/AuthenticationController.java +++ b/backend/src/main/java/com/mailserver/controller/AuthenticationController.java @@ -1,5 +1,6 @@ package com.mailserver.controller; +import com.mailserver.config.MailAdmins; import com.mailserver.config.MailDomains; import com.mailserver.domain.User; import com.mailserver.dto.LoginRequest; @@ -55,11 +56,13 @@ public class AuthenticationController { private final MailDomains domains; private final boolean registrationEnabled; private final LoginGuard guard; + private final MailAdmins admins; public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, - LoginGuard guard) { + LoginGuard guard, MailAdmins admins) { this.guard = guard; + this.admins = admins; this.userService = userService; this.mailService = mailService; this.jwtUtils = jwtUtils; @@ -179,7 +182,8 @@ public class AuthenticationController { private Mono profile(User user) { return mailService.usedBytes(user.getId()) .map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, - user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt())); + user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(), + admins.isAdmin(user.getEmail()))); } private Mono user(Mono auth) { diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java new file mode 100644 index 0000000..2171a94 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -0,0 +1,165 @@ +package com.mailserver.controller; + +import com.mailserver.config.MailAdmins; +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; +import com.mailserver.service.MailService; +import com.mailserver.service.UserService; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.Comparator; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@, + * support@ — registration stays closed), new password, switch off. A generated password is returned once, in the + * answer — it is not logged. + */ +@RestController +@RequestMapping("/api/admin/mailboxes") +public class MailboxAdminController { + + private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class); + private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?"); + private static final int MIN_PASSWORD = 10; + + public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, + long usedBytes, long quotaBytes, Instant createdAt) { + } + + /** @param password empty — the server generates one and returns it once */ + public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName, + @Size(max = 200) String password) { + } + + public record PasswordRequest(@Size(max = 200) String password) { + } + + public record ActiveRequest(boolean active) { + } + + /** @param password the generated password, or null when the administrator chose it */ + public record Created(MailboxView mailbox, String password) { + } + + private final UserService users; + private final MailService mail; + private final MailDomains domains; + private final MailAdmins admins; + + public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) { + this.users = users; + this.mail = mail; + this.domains = domains; + this.admins = admins; + } + + @GetMapping + public Flux list(Mono auth) { + return admin(auth).thenMany(users.all()) + .concatMap(this::view) + .sort(Comparator.comparing(MailboxView::email)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Mono create(Mono auth, @RequestBody Mono body) { + return admin(auth).then(body).flatMap(request -> { + String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT); + int at = email.lastIndexOf('@'); + if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) { + return Mono.error(bad("Not a mailbox address: " + email)); + } + if (!domains.isLocal(email)) { + return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList()))); + } + String chosen = request.password() == null ? "" : request.password(); + if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) { + return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one")); + } + String password = chosen.isEmpty() ? randomPassword() : chosen; + return users.register(email, password, request.displayName()) + .onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")) + .doOnNext(user -> log.info("Mailbox created by administrator: {}", email)) + .flatMap(this::view) + .map(view -> new Created(view, chosen.isEmpty() ? password : null)); + }); + } + + /** New password: the given one, or a generated one returned once. */ + @PostMapping("/{id}/password") + public Mono password(Mono auth, @PathVariable Long id, @RequestBody Mono body) { + return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> { + String chosen = request.password() == null ? "" : request.password(); + if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) { + return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one")); + } + String password = chosen.isEmpty() ? randomPassword() : chosen; + return mailbox(id) + .flatMap(user -> users.setPassword(user, password)) + .doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail())) + .flatMap(this::view) + .map(view -> new Created(view, chosen.isEmpty() ? password : null)); + }); + } + + /** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */ + @PutMapping("/{id}/active") + public Mono active(Mono auth, @PathVariable Long id, @RequestBody Mono body) { + return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> { + if (!request.active() && user.getEmail().equalsIgnoreCase(me)) { + return Mono.error(bad("You can not switch off your own mailbox")); + } + log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off"); + return users.setActive(user, request.active()); + }))).flatMap(this::view); + } + + private Mono admin(Mono auth) { + return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) + .map(Authentication::getName) + .filter(admins::isAdmin) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators"))); + } + + private Mono mailbox(Long id) { + return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found"))); + } + + private Mono view(User user) { + return mail.usedBytes(user.getId()).defaultIfEmpty(0L) + .map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(), + admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), + user.getCreatedAt())); + } + + private static ResponseStatusException bad(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + static String randomPassword() { + byte[] bytes = new byte[18]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/backend/src/main/java/com/mailserver/dto/ProfileResponse.java b/backend/src/main/java/com/mailserver/dto/ProfileResponse.java index 3415733..132a3a2 100644 --- a/backend/src/main/java/com/mailserver/dto/ProfileResponse.java +++ b/backend/src/main/java/com/mailserver/dto/ProfileResponse.java @@ -2,5 +2,7 @@ package com.mailserver.dto; import java.time.Instant; -public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) { +/** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */ +public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt, + boolean admin) { } diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index b5540d4..0a3bcbd 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -74,6 +74,25 @@ public class UserService { return userRepository.save(user).then(); } + public Flux all() { + return userRepository.findAll(); + } + + public Mono byId(Long id) { + return userRepository.findById(id); + } + + /** Administrator sets a new password (Settings → Mailboxes): no current password needed. */ + public Mono setPassword(User user, String newPassword) { + user.setPassword(passwordEncoder.encode(newPassword)); + return userRepository.save(user); + } + + public Mono setActive(User user, boolean active) { + user.setActive(active); + return userRepository.save(user); + } + public Mono updateDisplayName(User user, String displayName) { user.setDisplayName(displayName.trim()); return userRepository.save(user); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 39d4a55..110665b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:} mailserver.registration.enabled=${MAIL_REGISTRATION:false} mailserver.admin.email=${MAIL_ADMIN_EMAIL:} mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} +# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@) +mailserver.admins=${MAIL_ADMINS:} # Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once mailserver.passwords-file=${MAIL_PASSWORDS_FILE:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:} diff --git a/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java index bf56d4c..33c7a0f 100644 --- a/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/AuthenticationControllerTest.java @@ -34,7 +34,7 @@ class AuthenticationControllerTest { private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO)); private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, - mock(MailDomains.class), false, guard); + mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class)); private final User alice = new User(); diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java new file mode 100644 index 0000000..ccb4252 --- /dev/null +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -0,0 +1,129 @@ +package com.mailserver.controller; + +import com.mailserver.config.MailAdmins; +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; +import com.mailserver.service.MailService; +import com.mailserver.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */ +class MailboxAdminControllerTest { + + private final UserService users = mock(UserService.class); + private final MailService mail = mock(MailService.class); + private final MailDomains domains = new MailDomains("jambocheck.com", ""); + private final MailAdmins admins = new MailAdmins("", "", domains); + private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins); + + private final User admin = user(1L, "admin@jambocheck.com"); + private final User alice = user(2L, "alice@jambocheck.com"); + + MailboxAdminControllerTest() { + when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L)); + when(users.all()).thenReturn(Flux.just(alice, admin)); + when(users.byId(1L)).thenReturn(Mono.just(admin)); + when(users.byId(2L)).thenReturn(Mono.just(alice)); + when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0)))); + when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); + when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> { + User u = i.getArgument(0); + u.setActive(i.getArgument(1)); + return Mono.just(u); + }); + } + + @Test + void administratorIsTheFirstMailboxByDefault() { + assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue(); + assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse(); + assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse(); + } + + @Test + void listsMailboxesSortedForAnAdministratorOnly() { + List list = controller.list(as("admin@jambocheck.com")).collectList().block(); + assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com"); + assertThat(list.get(0).admin()).isTrue(); + + assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void createsReservedNamesWithAGeneratedPasswordShownOnce() { + var created = controller.create(as("admin@jambocheck.com"), + Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block(); + + assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com"); + assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20); + verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron")); + + var chosen = controller.create(as("admin@jambocheck.com"), + Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block(); + assertThat(chosen.password()).isNull(); + } + + @Test + void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() { + assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN); + verify(users, never()).register(anyString(), anyString(), any()); + + when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists"))); + assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void newPasswordAndSwitchOffButNotYourself() { + var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block(); + assertThat(reset.password()).isNotBlank(); + verify(users).setPassword(alice, reset.password()); + + assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse(); + assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block())) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + private MailboxAdminController.Created create(String me, String email, String password) { + return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block(); + } + + private static Mono as(String email) { + return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of())); + } + + private static HttpStatus status(Runnable call) { + Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run); + assertThat(error).isInstanceOf(ResponseStatusException.class); + return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value()); + } + + private static User user(Long id, String email) { + User user = new User(); + user.setId(id); + user.setEmail(email); + user.setPassword("hash"); + return user; + } +} diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index 427790e..d74b53f 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models'; +import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models'; /** The mail server REST API (same origin, /api). */ @Injectable({ providedIn: 'root' }) @@ -17,6 +17,18 @@ export class ApiService { return this.http.put('/api/me/password', { currentPassword, newPassword }); } + // mailboxes — administrators only + mailboxes(): Observable { return this.http.get('/api/admin/mailboxes'); } + createMailbox(email: string, displayName: string, password: string): Observable { + return this.http.post('/api/admin/mailboxes', { email, displayName, password }); + } + newMailboxPassword(id: number): Observable { + return this.http.post(`/api/admin/mailboxes/${id}/password`, {}); + } + setMailboxActive(id: number, active: boolean): Observable { + return this.http.put(`/api/admin/mailboxes/${id}/active`, { active }); + } + // folders folders(): Observable { return this.http.get('/api/folders'); } createFolder(name: string): Observable { return this.http.post('/api/folders', { name }); } diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index ebc4126..b981ff2 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -53,6 +53,25 @@ export interface Profile { usedBytes: number; quotaBytes: number; createdAt: string; + admin?: boolean; // may manage mailboxes (Settings → Mailboxes) +} + +/** MailboxAdminController.MailboxView */ +export interface Mailbox { + id: number; + email: string; + displayName: string; + active: boolean; + admin: boolean; + usedBytes: number; + quotaBytes: number; + createdAt: string; +} + +/** password — generated by the server, shown once; null when the administrator chose it */ +export interface MailboxCreated { + mailbox: Mailbox; + password: string | null; } export interface ServerConfig { diff --git a/frontend/src/app/pages/settings/mailboxes.section.ts b/frontend/src/app/pages/settings/mailboxes.section.ts new file mode 100644 index 0000000..ff388d7 --- /dev/null +++ b/frontend/src/app/pages/settings/mailboxes.section.ts @@ -0,0 +1,124 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatIconModule } from '@angular/material/icon'; +import { ApiService } from '../../core/api.service'; +import { MailStateService } from '../../core/mail-state.service'; +import { Mailbox, errorText, formatBytes } from '../../core/models'; +import { ToastService } from '../../ui/toast.service'; + +/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */ +@Component({ + selector: 'app-mailboxes-section', + imports: [FormsModule, MatIconModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

+ group MAILBOXES +

+ +
+
+
+
+
+
+
+ +
+ @if (error()) { } + + @if (secret(); as s) { +
+
Password of {{ s.email }} — shown once, copy it now
+
+ {{ s.password }} + + +
+
+ } + +
    + @for (m of mailboxes(); track m.id) { +
  • + {{ m.email }} + {{ m.displayName }} + @if (m.admin) { ADMIN } + {{ size(m.usedBytes) }} + + @if (m.email !== me()) { + + } +
  • + } +
+
+ ` +}) +export class MailboxesSection { + private api = inject(ApiService); + private state = inject(MailStateService); + private toasts = inject(ToastService); + + readonly mailboxes = signal([]); + readonly secret = signal<{ email: string; password: string } | null>(null); + readonly error = signal(''); + readonly busy = signal(false); + readonly me = computed(() => this.state.profile()?.email ?? ''); + readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com'); + + email = ''; + name = ''; + password = ''; + + constructor() { + this.load(); + } + + size(bytes: number): string { + return formatBytes(bytes); + } + + load(): void { + this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) }); + } + + create(): void { + const email = this.email.trim(); + if (!email) { this.error.set('Enter an address'); return; } + this.error.set(''); + this.busy.set(true); + this.api.createMailbox(email, this.name.trim(), this.password).subscribe({ + next: created => { + this.busy.set(false); + this.email = this.name = this.password = ''; + if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); } + this.toasts.ok('Mailbox ' + created.mailbox.email + ' created'); + this.load(); + }, + error: e => { this.busy.set(false); this.error.set(errorText(e)); } + }); + } + + newPassword(m: Mailbox): void { + if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return; + this.api.newMailboxPassword(m.id).subscribe({ + next: created => created.password && this.secret.set({ email: m.email, password: created.password }), + error: e => this.toasts.error(errorText(e)) + }); + } + + toggle(m: Mailbox): void { + this.api.setMailboxActive(m.id, !m.active).subscribe({ + next: () => this.load(), + error: e => this.toasts.error(errorText(e)) + }); + } + + copy(text: string): void { + navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand')); + } +} diff --git a/frontend/src/app/pages/settings/settings.page.ts b/frontend/src/app/pages/settings/settings.page.ts index f877d8e..4919f56 100644 --- a/frontend/src/app/pages/settings/settings.page.ts +++ b/frontend/src/app/pages/settings/settings.page.ts @@ -7,11 +7,12 @@ import { MailStateService } from '../../core/mail-state.service'; import { errorText, formatBytes } from '../../core/models'; import { THEMES, ThemeService } from '../../ui/theme.service'; import { ToastService } from '../../ui/toast.service'; +import { MailboxesSection } from './mailboxes.section'; /** Profile name, password, storage, look. */ @Component({ selector: 'app-settings', - imports: [DatePipe, FormsModule, MatIconModule], + imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -97,6 +98,10 @@ import { ToastService } from '../../ui/toast.service';
+ + @if (state.profile()?.admin) { + + } ` }) -- 2.54.0