Compare commits

...
2 Commits
Author SHA1 Message Date
Liosha84 4ad2e44e9b Merge pull request 'Ящики из веб-почты: создать, новый пароль, выключить (для администратора)' (#16) from feat/admin-mailboxes into master
continuous-integration/drone/push Build is passing
Reviewed-on: #16
2026-11-07 01:49:57 +01:00
Liosha84andClaude Opus 5.5 2149c29a32 Ящики из веб-почты: администратор создаёт no-reply@, monitor@, support@, задаёт новый пароль, выключает
continuous-integration/drone/pr Build is passing
Регистрация остаётся закрытой. Администраторы — MAIL_ADMINS (по умолчанию первый ящик);
роль не в базе, укравший ящик себя админом не сделает. API /api/admin/mailboxes,
сгенерированный пароль — один раз в ответе, в лог не пишется. Настройки → MAILBOXES
(видно только администратору). Тесты: MailboxAdminControllerTest (5), всего 73.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:23:48 +01:00
13 changed files with 524 additions and 6 deletions
+1
View File
@@ -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` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
@@ -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@&lt;first domain&gt;. 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<String> 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));
}
}
@@ -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<ProfileResponse> 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> user(Mono<Authentication> auth) {
@@ -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<MailboxView> list(Mono<Authentication> auth) {
return admin(auth).thenMany(users.all())
.concatMap(this::view)
.sort(Comparator.comparing(MailboxView::email));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> 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<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> 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<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> 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<String> admin(Mono<Authentication> 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<User> mailbox(Long id) {
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
}
private Mono<MailboxView> 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);
}
}
@@ -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) {
}
@@ -74,6 +74,25 @@ public class UserService {
return userRepository.save(user).then();
}
public Flux<User> all() {
return userRepository.findAll();
}
public Mono<User> byId(Long id) {
return userRepository.findById(id);
}
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
public Mono<User> setPassword(User user, String newPassword) {
user.setPassword(passwordEncoder.encode(newPassword));
return userRepository.save(user);
}
public Mono<User> setActive(User user, boolean active) {
user.setActive(active);
return userRepository.save(user);
}
public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim());
return userRepository.save(user);
@@ -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@<first domain>)
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:}
@@ -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();
@@ -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<MailboxAdminController.MailboxView> 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<Authentication> 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;
}
}
+13 -1
View File
@@ -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<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
}
newMailboxPassword(id: number): Observable<MailboxCreated> {
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
// folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
+19
View File
@@ -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 {
@@ -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: `
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
</h2>
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
<div><label class="synth-label" for="mb-email">Address</label>
<input id="mb-email" name="email" class="synth-input" [(ngModel)]="email" [placeholder]="'no-reply@' + domain()" autocomplete="off" required></div>
<div><label class="synth-label" for="mb-name">Name</label>
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div>
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
@if (secret(); as s) {
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
<div class="flex items-center gap-2">
<code class="font-mono text-pink-300 break-all select-all">{{ s.password }}</code>
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
</div>
</div>
}
<ul class="divide-y divide-cyan-500/10">
@for (m of mailboxes(); track m.id) {
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
}
</li>
}
</ul>
</section>
`
})
export class MailboxesSection {
private api = inject(ApiService);
private state = inject(MailStateService);
private toasts = inject(ToastService);
readonly mailboxes = signal<Mailbox[]>([]);
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'));
}
}
@@ -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: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -97,6 +98,10 @@ import { ToastService } from '../../ui/toast.service';
</div>
</div>
</section>
@if (state.profile()?.admin) {
<app-mailboxes-section />
}
</div>
`
})