Compare commits

...
Author SHA1 Message Date
Liosha84 44c8e17a27 Merge pull request 'Администратор открывает любой ящик (OPEN); @домен при создании ящика; «показать пароль» везде' (#21) from feat/admin-open-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #21
2026-11-07 03:12:23 +01:00
Liosha84andClaude Opus 5.5 aedda06f2e Веб-почта: при создании ящика — только имя, @домен подставляется (выбор, если доменов несколько); глазок «показать пароль» у всех полей пароля
continuous-integration/drone/pr Build is passing
Регистрация, смена пароля, создание ящика (вход уже был).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:11:42 +01:00
Liosha84andClaude Opus 5.5 91bb8b9b2d Администратор открывает любой ящик: MAILBOXES → OPEN, полоса «вернуться в admin@»
continuous-integration/drone/pr Build is passing
POST /api/admin/mailboxes/{id}/open — сессия этого ящика без его пароля, только для
MAIL_ADMINS; каждое открытие — в лог (кто какой ящик открыл). В веб-почте: кнопка OPEN
у ящика, сверху розовая полоса «Вы в ящике X как администратор — Back to admin@».
Тесты: +1 (всего 75), ng build OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:09:29 +01:00
Liosha84 18ee4820c1 Merge pull request 'Веб-почта: колонка Subject на ~1 см уже — без горизонтальной прокрутки' (#20) from fix/subject-width into master
continuous-integration/drone/push Build is passing
Reviewed-on: #20
2026-11-07 03:03:15 +01:00
Liosha84andClaude Opus 5.5 59e2c11d6a Веб-почта: колонка Subject на ~1 см уже (560 → 520 px) — без горизонтальной прокрутки
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:02:31 +01:00
Liosha84 84ae0b6c7d Merge pull request 'README: начальный пароль admin@ — из файла, не из лога' (#19) from docs/password-file-not-log into master
continuous-integration/drone/push Build is passing
Reviewed-on: #19
2026-11-07 02:50:58 +01:00
Liosha84andClaude Opus 5.5 0d0f4800f0 README: пароль admin@jambocheck.com (по решению владельца)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:22 +01:00
Liosha84andClaude Opus 5.5 b009af972a README: где пароли ящиков — SECRETS.md закрытого репозитория Jambotron (этот репозиторий публичный)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:50:08 +01:00
Liosha84andClaude Opus 5.5 100ab741f3 README: начальный пароль admin@ — из файла в томе, не из лога (лог пропадает при передеплое)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:49:14 +01:00
Liosha84 bdca471b97 Merge pull request 'README: «Забыл пароль — где взять» первым разделом' (#18) from docs/forgot-admin-password into master
continuous-integration/drone/push Build is passing
Reviewed-on: #18
2026-11-07 02:41:25 +01:00
Liosha84andClaude Opus 5.5 639ab0a6e9 README: первым разделом — забыл пароль, где взять (admin@ из логов, сброс, MAILBOXES)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:40:52 +01:00
Liosha84 f1d66d4c7d Merge pull request 'Веб-почта по HTTPS: https://mail.jambocheck.com (порт 443)' (#17) from feat/https-443 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #17
2026-11-07 02:22:03 +01:00
Liosha84andClaude Opus 5.5 f9b929ac7a Веб-почта по HTTPS на том же адресе: https://mail.jambocheck.com (порт 443)
continuous-integration/drone/pr Build is passing
HTTPS_PORT — второй Netty-сервер с тем же HttpHandler и тем же сертификатом, что SMTP/IMAP
(Let's Encrypt от acme, перечитывается при продлении). HSTS добавляет Spring Security.
docker-compose.public.yml: 443 → 8443. HTTP 8080 остаётся для прокси внутри сети.
Тесты: HttpsConfigTest, всего 74; живой запуск: /api/config по HTTPS, HSTS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:18:42 +01:00
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
Liosha84 048d6b3679 Merge pull request 'docker-compose.public.yml: значения по умолчанию для jambocheck.com (деплой без .env)' (#15) from fix/public-compose-defaults into master
continuous-integration/drone/push Build is passing
Reviewed-on: #15
2026-11-06 20:20:21 +01:00
20 changed files with 822 additions and 15 deletions
+19 -1
View File
@@ -2,7 +2,23 @@
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC. Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend. Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
## Забыл пароль — где взять
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
| Ситуация | Что делать |
| :--- | :--- |
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
## Как устроено ## Как устроено
@@ -67,6 +83,8 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` | | `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` | | `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` | | `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) | | `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
| `MAIL_REGISTRATION` | открыть регистрацию | `false` | | `MAIL_REGISTRATION` | открыть регистрацию | `false` |
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — | | `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
@@ -0,0 +1,81 @@
package com.mailserver.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
import reactor.netty.DisposableServer;
import reactor.netty.http.server.HttpServer;
import java.time.Duration;
/**
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
*/
@Configuration
public class HttpsConfig {
@Bean
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
@Value("${mailserver.https.port}") int port) {
return new HttpsServer(handler, tls, host, port);
}
public static class HttpsServer implements SmartLifecycle {
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
private final HttpHandler handler;
private final MailServerConfig.SmtpTlsContext tls;
private final String host;
private final int port;
private volatile DisposableServer server;
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
this.handler = handler;
this.tls = tls;
this.host = host;
this.port = port;
}
@Override
public void start() {
server = HttpServer.create()
.host(host)
.port(port)
.secure(spec -> spec.sslContext(tls.context()))
// HSTS: Spring Security adds it for HTTPS requests by itself
.handle(new ReactorHttpHandlerAdapter(handler))
.bindNow(Duration.ofSeconds(30));
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
}
@Override
public void stop() {
DisposableServer current = server;
if (current != null) {
current.disposeNow(Duration.ofSeconds(10));
server = null;
}
}
@Override
public boolean isRunning() {
return server != null;
}
public int port() {
return server == null ? -1 : server.port();
}
}
}
@@ -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; package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.LoginRequest; import com.mailserver.dto.LoginRequest;
@@ -55,11 +56,13 @@ public class AuthenticationController {
private final MailDomains domains; private final MailDomains domains;
private final boolean registrationEnabled; private final boolean registrationEnabled;
private final LoginGuard guard; private final LoginGuard guard;
private final MailAdmins admins;
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains, public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled, @Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
LoginGuard guard) { LoginGuard guard, MailAdmins admins) {
this.guard = guard; this.guard = guard;
this.admins = admins;
this.userService = userService; this.userService = userService;
this.mailService = mailService; this.mailService = mailService;
this.jwtUtils = jwtUtils; this.jwtUtils = jwtUtils;
@@ -179,7 +182,8 @@ public class AuthenticationController {
private Mono<ProfileResponse> profile(User user) { private Mono<ProfileResponse> profile(User user) {
return mailService.usedBytes(user.getId()) return mailService.usedBytes(user.getId())
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used, .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) { private Mono<User> user(Mono<Authentication> auth) {
@@ -0,0 +1,184 @@
package com.mailserver.controller;
import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains;
import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailService;
import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils;
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;
private final JwtUtils jwt;
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
this.jwt = jwt;
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));
});
}
/**
* Administrator opens a mailbox without its password (Settings → MAILBOXES → Open): a session of that mailbox.
* Every opening is logged — who opened which mailbox.
*/
@PostMapping("/{id}/open")
public Mono<LoginResponse> open(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).map(user -> {
log.warn("Administrator {} opened mailbox {}", me, user.getEmail());
LoginResponse response = new LoginResponse();
response.setToken(jwt.generateToken(user.getEmail()));
response.setEmail(user.getEmail());
return response;
}));
}
/** 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; 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(); 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) { public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim()); user.setDisplayName(displayName.trim());
return userRepository.save(user); return userRepository.save(user);
@@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:}
mailserver.registration.enabled=${MAIL_REGISTRATION:false} mailserver.registration.enabled=${MAIL_REGISTRATION:false}
mailserver.admin.email=${MAIL_ADMIN_EMAIL:} mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} 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 # 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.passwords-file=${MAIL_PASSWORDS_FILE:}
mailserver.cors.origins=${MAIL_CORS_ORIGINS:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
@@ -47,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:} mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:} mailserver.tls.key-file=${MAIL_TLS_KEY:}
mailserver.tls.dir=${MAIL_TLS_DIR:./tls} mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
mailserver.https.port=${HTTPS_PORT:0}
mailserver.smtps.port=${SMTPS_PORT:0} mailserver.smtps.port=${SMTPS_PORT:0}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend) # Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
@@ -0,0 +1,45 @@
package com.mailserver.config;
import com.mailserver.smtp.SmtpTls;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.HttpHandler;
import reactor.core.publisher.Mono;
import reactor.netty.http.client.HttpClient;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
class HttpsConfigTest {
@Test
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
HttpHandler handler = (request, response) -> {
response.setStatusCode(HttpStatus.OK);
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
};
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
server.start();
try {
assertThat(server.isRunning()).isTrue();
String body = HttpClient.create()
.secure(spec -> spec.sslContext(trustAll))
.get().uri("https://127.0.0.1:" + server.port() + "/")
.responseContent().aggregate().asString().block();
assertThat(body).isEqualTo("hello https");
} finally {
server.stop();
}
assertThat(server.isRunning()).isFalse();
}
}
@@ -34,7 +34,7 @@ class AuthenticationControllerTest {
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15), 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)); Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt, 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(); private final User alice = new User();
@@ -0,0 +1,143 @@
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 com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class);
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt);
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.byId(99L)).thenReturn(Mono.empty());
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);
}
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com")).thenReturn("alice-token");
var session = controller.open(as("admin@jambocheck.com"), 2L).block();
assertThat(session.getEmail()).isEqualTo("alice@jambocheck.com");
assertThat(session.getToken()).isEqualTo("alice-token");
assertThat(status(() -> controller.open(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.open(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
}
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;
}
}
+5 -2
View File
@@ -8,7 +8,8 @@
# 465 SMTPS: mail programs, implicit TLS # 465 SMTPS: mail programs, implicit TLS
# 993 IMAPS: mail programs, implicit TLS # 993 IMAPS: mail programs, implicit TLS
# 80 only for Let's Encrypt (HTTP-01) — service "acme" # 80 only for Let's Encrypt (HTTP-01) — service "acme"
# The web client + API (service "mail", port 8080) goes through Jambotron's Caddy under ANOTHER name # 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy. # (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
# #
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME): # DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
@@ -121,8 +122,9 @@ services:
- "587:3025" - "587:3025"
- "465:3465" - "465:3465"
- "993:3993" - "993:3993"
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
expose: expose:
- "8080" # web client + API → webmail.<domain> in Jambotron - "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
environment: environment:
DB_HOST: db DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets MAILSERVER_SECRETS_DIR: /run/mail-secrets
@@ -132,6 +134,7 @@ services:
MAIL_TLS_KEY: /tls/key.pem MAIL_TLS_KEY: /tls/key.pem
SMTPS_PORT: "3465" SMTPS_PORT: "3465"
IMAPS_PORT: "3993" IMAPS_PORT: "3993"
HTTPS_PORT: "8443"
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com} DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
DKIM_SELECTOR: mail DKIM_SELECTOR: mail
DKIM_KEYS_DIR: /app/data/dkim-keys DKIM_KEYS_DIR: /app/data/dkim-keys
+16 -1
View File
@@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; 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). */ /** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -17,6 +17,21 @@ export class ApiService {
return this.http.put('/api/me/password', { currentPassword, newPassword }); 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`, {});
}
openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
// folders // folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); } folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); } createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
+31
View File
@@ -12,6 +12,8 @@ export class AuthService {
private router = inject(Router); private router = inject(Router);
readonly email = signal<string | null>(read('email')); readonly email = signal<string | null>(read('email'));
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
readonly openedBy = signal<string | null>(read('adminEmail'));
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null); readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
login(email: string, password: string): Observable<TokenResponse> { login(email: string, password: string): Observable<TokenResponse> {
@@ -24,6 +26,32 @@ export class AuthService {
token(): string | null { return read('token'); } token(): string | null { return read('token'); }
/** Administrator opens another mailbox: its session, the admin session kept to come back. */
openAs(response: TokenResponse): void {
const adminToken = read('token');
const adminEmail = read('email');
try {
if (adminToken && adminEmail && !read('adminToken')) {
localStorage.setItem('adminToken', adminToken);
localStorage.setItem('adminEmail', adminEmail);
}
} catch { /* storage blocked */ }
this.store(response);
this.openedBy.set(read('adminEmail'));
}
/** Back to the administrator's own mailbox. */
backToAdmin(): void {
const token = read('adminToken');
const email = read('adminEmail');
try {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ }
this.openedBy.set(null);
if (token && email) { this.store({ token, email }); } else { this.clear(); }
}
logout(): void { logout(): void {
this.clear(); this.clear();
this.router.navigate(['/login']); this.router.navigate(['/login']);
@@ -33,8 +61,11 @@ export class AuthService {
try { try {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('email'); localStorage.removeItem('email');
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ } } catch { /* storage blocked */ }
this.email.set(null); this.email.set(null);
this.openedBy.set(null);
} }
private store(response: TokenResponse): void { private store(response: TokenResponse): void {
+19
View File
@@ -53,6 +53,25 @@ export interface Profile {
usedBytes: number; usedBytes: number;
quotaBytes: number; quotaBytes: number;
createdAt: string; 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 { export interface ServerConfig {
+11 -2
View File
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
</div> </div>
<div> <div>
<label class="synth-label" for="password">Password (8+ characters)</label> <label class="synth-label" for="password">Password (8+ characters)</label>
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()" <div class="relative"><input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password()"
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password"> (ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div>
</div> </div>
<div> <div>
<label class="synth-label" for="password2">Password again</label> <label class="synth-label" for="password2">Password again</label>
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()" <div class="relative"><input id="password2" name="password2" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password2()"
(ngModelChange)="password2.set($event)" required autocomplete="new-password"> (ngModelChange)="password2.set($event)" required autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div>
</div> </div>
@if (error()) { @if (error()) {
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert"> <p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
@@ -79,6 +87,7 @@ export class RegisterPage {
readonly loaded = signal(false); readonly loaded = signal(false);
readonly enabled = signal(false); readonly enabled = signal(false);
readonly show = signal(false);
readonly domains = signal<string[]>([]); readonly domains = signal<string[]>([]);
readonly domain = signal(''); readonly domain = signal('');
readonly local = signal(''); readonly local = signal('');
+1 -1
View File
@@ -67,7 +67,7 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</ng-template> </ng-template>
<ng-template jtGridCell="subject" let-row> <ng-template jtGridCell="subject" let-row>
<a [routerLink]="link(row)" class="block max-w-[560px] group"> <a [routerLink]="link(row)" class="block max-w-[520px] group">
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read"> <span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> } @if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
{{ row.subject || '(no subject)' }} {{ row.subject || '(no subject)' }}
@@ -0,0 +1,162 @@
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';
import { AuthService } from '../../core/auth.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>
<div class="flex items-stretch gap-2">
<input id="mb-email" name="email" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="no-reply" autocomplete="off" required>
<span class="flex items-center text-cyan-300 font-mono">&#64;</span>
@if (domains().length > 1) {
<select name="domain" class="synth-input !w-auto" [ngModel]="domain()" (ngModelChange)="domain.set($event)" aria-label="Domain">
@for (d of domains(); track d) { <option [value]="d">{{ d }}</option> }
</select>
} @else {
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
}
</div></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>
<div class="relative"><input id="mb-password" name="password" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="password" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></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>
@if (m.email !== me()) {
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
<mat-icon>mark_email_read</mat-icon> OPEN</button>
}
<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);
private auth = inject(AuthService);
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 showPw = signal(false);
readonly local = signal('');
// Домены сервера (/api/config); пока не пришли — домен своего ящика
readonly domains = signal<string[]>([]);
readonly domain = signal('');
name = '';
password = '';
constructor() {
this.domain.set(this.me().split('@')[1] ?? '');
this.api.config().subscribe({
next: c => { this.domains.set(c.domains); if (!c.domains.includes(this.domain())) { this.domain.set(c.domains[0] ?? ''); } },
error: () => {}
});
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 local = this.local().replace(/@.*$/, '');
if (!local) { this.error.set('Enter an address'); return; }
const email = local + '@' + this.domain();
this.error.set('');
this.busy.set(true);
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
next: created => {
this.busy.set(false);
this.local.set('');
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))
});
}
/** Administrator reads this mailbox: its session; the banner on top leads back. Full reload — fresh folders. */
open(m: Mailbox): void {
this.api.openMailbox(m.id).subscribe({
next: session => { this.auth.openAs(session); location.assign('/'); },
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 { errorText, formatBytes } from '../../core/models';
import { THEMES, ThemeService } from '../../ui/theme.service'; import { THEMES, ThemeService } from '../../ui/theme.service';
import { ToastService } from '../../ui/toast.service'; import { ToastService } from '../../ui/toast.service';
import { MailboxesSection } from './mailboxes.section';
/** Profile name, password, storage, look. */ /** Profile name, password, storage, look. */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule], imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
template: ` template: `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
@@ -41,11 +42,23 @@ import { ToastService } from '../../ui/toast.service';
<form (ngSubmit)="changePassword()" class="space-y-3"> <form (ngSubmit)="changePassword()" class="space-y-3">
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true"> <input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
<div><label class="synth-label" for="pw-current">Current password</label> <div><label class="synth-label" for="pw-current">Current password</label>
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div> <div class="relative"><input id="pw-current" name="current" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="current" autocomplete="current-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label> <div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div> <div class="relative"><input id="pw-new" name="next" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="next" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
<div><label class="synth-label" for="pw-again">New password again</label> <div><label class="synth-label" for="pw-again">New password again</label>
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div> <div class="relative"><input id="pw-again" name="again" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="again" autocomplete="new-password">
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
</button></div></div>
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> } @if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button> <button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
</form> </form>
@@ -97,6 +110,10 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</div> </div>
</section> </section>
@if (state.profile()?.admin) {
<app-mailboxes-section />
}
</div> </div>
` `
}) })
@@ -112,6 +129,7 @@ export class SettingsPage {
next = ''; next = '';
again = ''; again = '';
readonly passwordError = signal(''); readonly passwordError = signal('');
readonly showPw = signal(false);
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes)); readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes)); readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
@@ -69,6 +69,15 @@ import { ToastService } from '../../ui/toast.service';
</div> </div>
</header> </header>
@if (auth.openedBy(); as admin) {
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
<mat-icon class="!text-base">visibility</mat-icon>
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
</div>
}
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5"> <div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()" <nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders"> aria-label="Mail folders">
@@ -247,6 +256,11 @@ export class ShellComponent {
} }
} }
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void { logout(): void {
this.state.reset(); this.state.reset();
this.auth.logout(); this.auth.logout();