Compare commits

...
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 fc3fe3970d Ловушка у ящика (TRAP): что ящик отправляет — в его же INBOX, наружу ничего
continuous-integration/drone/pr Build is passing
Для staging@: staging-стенд шлёт через настоящий mail.jambocheck.com,
но тестовые письма не доходят до живых людей.
- V5: users.is_trap
- MailRouter: отправитель-ловушка → все получатели LOCAL, одна копия в INBOX отправителя
- PUT /api/admin/mailboxes/{id}/trap, MailboxView.trap
- MAILBOXES: кнопка outbox/move_to_inbox, метка TRAP
- тесты: router + admin (77 тестов)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:45:20 +01:00
Liosha84 0811dfcf68 Merge pull request 'Ящики: пароль от 8 символов (было 10)' (#24) from fix/mailbox-password-8 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #24
2026-11-07 04:00:33 +01:00
Liosha84andClaude Opus 5.5 714c62f285 Ящики: минимальная длина пароля 8 (как при регистрации и смене пароля), было 10
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:51:47 +01:00
Liosha84 e37487d509 Merge pull request 'Веб-почта: MAILBOXES на две колонки; левое меню вровень с панелью' (#23) from fix/layout-leftovers into master
continuous-integration/drone/push Build is passing
Reviewed-on: #23
2026-11-07 03:41:44 +01:00
Liosha84andClaude Opus 5.5 312e65fc33 Веб-почта: левое меню вровень с панелью — sticky top 96 → 80 px
continuous-integration/drone/pr Build is passing
Без прокрутки меню стоит на ~86 px, а sticky top-24 (96 px) сдвигал его ниже панели.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 5c08d7e361 Веб-почта: левое меню — верхний отступ как у панели посередине (md: 24 px)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84andClaude Opus 5.5 ccd31c503e Веб-почта: MAILBOXES на две колонки (col-span на элементе компонента — он и есть ячейка сетки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:37:27 +01:00
Liosha84 532f6ccab8 Merge pull request 'Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)' (#22) from fix/subject-width-2 into master
continuous-integration/drone/push Build is passing
Reviewed-on: #22
2026-11-07 03:25:56 +01:00
Liosha84andClaude Opus 5.5 9658cdfcd7 Веб-почта: колонка Subject ещё на ~1 см уже (520 → 480 px)
continuous-integration/drone/pr Build is passing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 03:21:15 +01:00
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
20 changed files with 430 additions and 25 deletions
+20 -1
View File
@@ -2,7 +2,25 @@
Почтовый сервер на **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**.
**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб), никому не доставляется: одна копия ложится в его же INBOX, наружу не уходит ничего. Для `staging@` — тестовые письма staging-стенда (забыл пароль, уведомления) не попадают к живым людям; смотреть их — открыть `staging@` (OPEN). Входящая почта на такой ящик приходит как обычно.
**Администратор:** `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`.
## Как устроено
@@ -68,6 +86,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
| `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_REGISTRATION` | открыть регистрацию | `false` |
| `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();
}
}
}
@@ -3,8 +3,10 @@ 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;
@@ -41,9 +43,9 @@ 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;
private static final int MIN_PASSWORD = 8;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
long usedBytes, long quotaBytes, Instant createdAt) {
}
@@ -58,6 +60,9 @@ public class MailboxAdminController {
public record ActiveRequest(boolean active) {
}
public record TrapRequest(boolean trap) {
}
/** @param password the generated password, or null when the administrator chose it */
public record Created(MailboxView mailbox, String password) {
}
@@ -66,8 +71,10 @@ public class MailboxAdminController {
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) {
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) {
this.jwt = jwt;
this.users = users;
this.mail = mail;
this.domains = domains;
@@ -123,6 +130,21 @@ public class MailboxAdminController {
});
}
/**
* 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) {
@@ -135,6 +157,15 @@ public class MailboxAdminController {
}))).flatMap(this::view);
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
log.info("Mailbox {} trap {} by administrator {}", user.getEmail(), request.trap() ? "ON" : "OFF", me);
return users.setTrap(user, request.trap());
}))).flatMap(this::view);
}
private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName)
@@ -149,7 +180,7 @@ public class MailboxAdminController {
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(),
admins.isAdmin(user.getEmail()), user.isTrap(), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
user.getCreatedAt()));
}
@@ -30,6 +30,10 @@ public class User {
@Column("is_active")
private boolean isActive = true;
/** Trap: what this mailbox sends is stored in its own INBOX, never delivered (V5). */
@Column("is_trap")
private boolean isTrap = false;
@Column("created_at")
private Instant createdAt;
@@ -54,6 +58,9 @@ public class User {
public boolean isActive() { return isActive; }
public void setActive(boolean active) { isActive = active; }
public boolean isTrap() { return isTrap; }
public void setTrap(boolean trap) { isTrap = trap; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
@@ -93,6 +93,11 @@ public class UserService {
return userRepository.save(user);
}
public Mono<User> setTrap(User user, boolean trap) {
user.setTrap(trap);
return userRepository.save(user);
}
public Mono<User> updateDisplayName(User user, String displayName) {
user.setDisplayName(displayName.trim());
return userRepository.save(user);
@@ -17,6 +17,8 @@ import java.util.Locale;
* an existing mailbox of our domain → that mailbox; catch-all set → everything else into the catch-all mailbox
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox →
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
* A logged-in TRAP mailbox (staging@) sends nowhere: every recipient is accepted and ONE copy is stored in the
* sender's own INBOX — a staging stand uses the real server, but its test mail never reaches people.
*/
@Service
public class MailRouter implements SmtpBackend {
@@ -48,6 +50,9 @@ public class MailRouter implements SmtpBackend {
@Override
public Route route(String recipient, String authenticatedUser) {
if (isTrap(authenticatedUser)) {
return Route.LOCAL;
}
if (domains.isLocal(recipient) && mailboxExists(recipient)) {
return Route.LOCAL;
}
@@ -66,6 +71,11 @@ public class MailRouter implements SmtpBackend {
@Override
public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null;
if (isTrap(envelope.authenticatedUser())) {
localDelivery.deliver(message, envelope.authenticatedUser().toLowerCase(Locale.ROOT), envelope.remoteIp(),
envelope.mailFrom(), true);
return;
}
byte[] signed = null;
for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) {
@@ -92,6 +102,15 @@ public class MailRouter implements SmtpBackend {
return domains.catchAll();
}
/** The logged-in sender is an active trap mailbox. */
boolean isTrap(String authenticatedUser) {
if (authenticatedUser == null) {
return false;
}
User user = users.findByEmail(authenticatedUser.toLowerCase(Locale.ROOT)).block();
return user != null && user.isTrap();
}
private boolean mailboxExists(String address) {
return Boolean.TRUE.equals(users.existsByEmail(address.toLowerCase(Locale.ROOT)).block());
}
@@ -49,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
mailserver.tls.key-file=${MAIL_TLS_KEY:}
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}
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
@@ -0,0 +1,3 @@
-- Trap mailbox: what it sends is NOT delivered to anyone — every copy lands in its own INBOX (staging@: test mail
-- of a staging stand never reaches real people). Switched on by an administrator in Settings → MAILBOXES.
ALTER TABLE users ADD COLUMN is_trap BOOLEAN NOT NULL DEFAULT FALSE;
@@ -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();
}
}
@@ -33,7 +33,8 @@ class MailboxAdminControllerTest {
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 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");
@@ -43,6 +44,7 @@ class MailboxAdminControllerTest {
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 -> {
@@ -50,6 +52,20 @@ class MailboxAdminControllerTest {
u.setActive(i.getArgument(1));
return Mono.just(u);
});
when(users.setTrap(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setTrap(i.getArgument(1));
return Mono.just(u);
});
}
@Test
void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
assertThat(alice.isTrap()).isTrue();
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(false))).block().trap()).isFalse();
assertThat(status(() -> controller.trap(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block()))
.isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@@ -105,6 +121,18 @@ class MailboxAdminControllerTest {
.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();
}
@@ -3,6 +3,7 @@ package com.mailserver.smtp;
import com.mailserver.config.MailDomains;
import com.mailserver.delivery.OutboundQueue;
import com.mailserver.delivery.SmtpDeliveryClient;
import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery;
@@ -30,10 +31,22 @@ class MailRouterTest {
private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
private static User user(String email, boolean trap) {
User user = new User();
user.setEmail(email);
user.setTrap(trap);
return user;
}
private MailRouter router(String catchAll) {
when(users.existsByEmail(anyString())).thenReturn(Mono.just(false));
when(users.existsByEmail("bob@mail.test")).thenReturn(Mono.just(true));
when(client.mode()).thenReturn(SmtpDeliveryClient.Mode.OFF);
when(users.findByEmail(anyString())).thenReturn(Mono.empty());
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false)));
when(users.findByEmail("staging@mail.test")).thenReturn(Mono.just(user("staging@mail.test", true)));
when(client.mode()).thenReturn(mode);
return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery,
queue, mock(DkimService.class), client);
}
@@ -65,4 +78,24 @@ class MailRouterTest {
verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
@Test
void trapMailboxSendsNowhereOneCopyLandsInItsOwnInbox() throws Exception {
mode = SmtpDeliveryClient.Mode.MX;
MailRouter router = router("");
// an ordinary mailbox still sends outside; the trap one keeps everything
assertThat(router.route("user@gmail.com", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("nobody@mail.test", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL);
router.accept(new SmtpBackend.Envelope("10.0.0.5", "staging", "staging@mail.test", "staging@mail.test", List.of(
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL),
new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes());
verify(localDelivery).deliver(any(), eq("staging@mail.test"), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(localDelivery, never()).deliver(any(), eq("bob@mail.test"), any(), any(), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any());
}
}
+5 -2
View File
@@ -8,7 +8,8 @@
# 465 SMTPS: mail programs, implicit TLS
# 993 IMAPS: mail programs, implicit TLS
# 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.
#
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
@@ -121,8 +122,9 @@ services:
- "587:3025"
- "465:3465"
- "993:3993"
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
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:
DB_HOST: db
MAILSERVER_SECRETS_DIR: /run/mail-secrets
@@ -132,6 +134,7 @@ services:
MAIL_TLS_KEY: /tls/key.pem
SMTPS_PORT: "3465"
IMAPS_PORT: "3993"
HTTPS_PORT: "8443"
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
DKIM_SELECTOR: mail
DKIM_KEYS_DIR: /app/data/dkim-keys
+6
View File
@@ -25,6 +25,12 @@ export class ApiService {
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`, {});
}
setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
}
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
+31
View File
@@ -12,6 +12,8 @@ export class AuthService {
private router = inject(Router);
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);
login(email: string, password: string): Observable<TokenResponse> {
@@ -24,6 +26,32 @@ export class AuthService {
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 {
this.clear();
this.router.navigate(['/login']);
@@ -33,8 +61,11 @@ export class AuthService {
try {
localStorage.removeItem('token');
localStorage.removeItem('email');
localStorage.removeItem('adminToken');
localStorage.removeItem('adminEmail');
} catch { /* storage blocked */ }
this.email.set(null);
this.openedBy.set(null);
}
private store(response: TokenResponse): void {
+1
View File
@@ -63,6 +63,7 @@ export interface Mailbox {
displayName: string;
active: boolean;
admin: boolean;
trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@)
usedBytes: number;
quotaBytes: number;
createdAt: string;
+11 -2
View File
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
</div>
<div>
<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">
<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>
<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">
<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>
@if (error()) {
<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 enabled = signal(false);
readonly show = signal(false);
readonly domains = signal<string[]>([]);
readonly domain = signal('');
readonly local = signal('');
+1 -1
View File
@@ -67,7 +67,7 @@ import { ToastService } from '../../ui/toast.service';
</div>
</ng-template>
<ng-template jtGridCell="subject" let-row>
<a [routerLink]="link(row)" class="block max-w-[560px] group">
<a [routerLink]="link(row)" class="block max-w-[480px] group">
<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> }
{{ row.subject || '(no subject)' }}
@@ -5,25 +5,43 @@ 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,
// ячейка сетки настроек — сам элемент компонента: на две колонки
host: { class: 'block xl:col-span-2' },
template: `
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
<section class="glass-panel static p-5 md:p-6" 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 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>
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div>
<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> }
@@ -45,7 +63,17 @@ import { ToastService } from '../../ui/toast.service';
<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> }
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</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)="trap(m)" [class.text-amber-300]="m.trap"
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></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)"
@@ -62,19 +90,27 @@ 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 domain = computed(() => this.me().split('@')[1] ?? 'example.com');
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();
}
@@ -87,14 +123,16 @@ export class MailboxesSection {
}
create(): void {
const email = this.email.trim();
if (!email) { this.error.set('Enter an address'); return; }
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.email = this.name = this.password = '';
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();
@@ -111,6 +149,23 @@ export class MailboxesSection {
});
}
/** 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))
});
}
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
trap(m: Mailbox): void {
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
next: () => { this.toasts.ok('Trap ' + (m.trap ? 'off' : 'on') + ' for ' + m.email); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
toggle(m: Mailbox): void {
this.api.setMailboxActive(m.id, !m.active).subscribe({
next: () => this.load(),
@@ -42,11 +42,23 @@ import { MailboxesSection } from './mailboxes.section';
<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">
<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>
<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>
<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> }
<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>
@@ -117,6 +129,7 @@ export class SettingsPage {
next = '';
again = '';
readonly passwordError = signal('');
readonly showPw = signal(false);
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
@@ -69,8 +69,17 @@ import { ToastService } from '../../ui/toast.service';
</div>
</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">
<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 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
aria-label="Mail folders">
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
<mat-icon>edit</mat-icon> COMPOSE
@@ -247,6 +256,11 @@ export class ShellComponent {
}
}
backToAdmin(): void {
this.auth.backToAdmin();
location.assign('/settings');
}
logout(): void {
this.state.reset();
this.auth.logout();