Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3603b9d315 | ||
|
|
fc3fe3970d | ||
|
|
0811dfcf68 | ||
|
|
714c62f285 | ||
|
|
e37487d509 | ||
|
|
312e65fc33 | ||
|
|
5c08d7e361 | ||
|
|
ccd31c503e | ||
|
|
532f6ccab8 | ||
|
|
9658cdfcd7 | ||
|
|
44c8e17a27 | ||
|
|
aedda06f2e | ||
|
|
91bb8b9b2d | ||
|
|
18ee4820c1 | ||
|
|
59e2c11d6a | ||
|
|
84ae0b6c7d | ||
|
|
0d0f4800f0 | ||
|
|
b009af972a | ||
|
|
100ab741f3 | ||
|
|
bdca471b97 | ||
|
|
639ab0a6e9 | ||
|
|
f1d66d4c7d | ||
|
|
f9b929ac7a | ||
|
|
4ad2e44e9b | ||
|
|
2149c29a32 | ||
|
|
048d6b3679 |
@@ -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`.
|
||||
|
||||
## Как устроено
|
||||
|
||||
@@ -67,6 +85,8 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
|
||||
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
||||
| `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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mailserver.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
|
||||
* mailbox — {@code mailserver.admin.email}, or admin@<first domain>. No role in the database: the list lives in
|
||||
* the configuration, so a stolen mailbox can not make itself an administrator.
|
||||
*/
|
||||
@Component
|
||||
public class MailAdmins {
|
||||
|
||||
private final Set<String> admins;
|
||||
|
||||
public MailAdmins(@Value("${mailserver.admins:}") String admins,
|
||||
@Value("${mailserver.admin.email:}") String firstMailbox,
|
||||
MailDomains domains) {
|
||||
String list = !admins.isBlank() ? admins
|
||||
: !firstMailbox.isBlank() ? firstMailbox
|
||||
: "admin@" + domains.domains().iterator().next();
|
||||
this.admins = Arrays.stream(list.split(","))
|
||||
.map(a -> a.trim().toLowerCase(Locale.ROOT))
|
||||
.filter(a -> !a.isEmpty())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
}
|
||||
|
||||
public boolean isAdmin(String email) {
|
||||
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mailserver.controller;
|
||||
|
||||
import com.mailserver.config.MailAdmins;
|
||||
import com.mailserver.config.MailDomains;
|
||||
import com.mailserver.domain.User;
|
||||
import com.mailserver.dto.LoginRequest;
|
||||
@@ -55,11 +56,13 @@ public class AuthenticationController {
|
||||
private final MailDomains domains;
|
||||
private final boolean registrationEnabled;
|
||||
private final LoginGuard guard;
|
||||
private final MailAdmins admins;
|
||||
|
||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
||||
LoginGuard guard) {
|
||||
LoginGuard guard, MailAdmins admins) {
|
||||
this.guard = guard;
|
||||
this.admins = admins;
|
||||
this.userService = userService;
|
||||
this.mailService = mailService;
|
||||
this.jwtUtils = jwtUtils;
|
||||
@@ -179,7 +182,8 @@ public class AuthenticationController {
|
||||
private Mono<ProfileResponse> profile(User user) {
|
||||
return mailService.usedBytes(user.getId())
|
||||
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt()));
|
||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
|
||||
admins.isAdmin(user.getEmail())));
|
||||
}
|
||||
|
||||
private Mono<User> user(Mono<Authentication> auth) {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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 = 8;
|
||||
|
||||
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
|
||||
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) {
|
||||
}
|
||||
|
||||
public record TrapRequest(boolean trap) {
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
|
||||
/** 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)
|
||||
.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()), user.isTrap(), 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ package com.mailserver.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) {
|
||||
/** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */
|
||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt,
|
||||
boolean admin) {
|
||||
}
|
||||
|
||||
@@ -74,6 +74,30 @@ public class UserService {
|
||||
return userRepository.save(user).then();
|
||||
}
|
||||
|
||||
public Flux<User> all() {
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
public Mono<User> byId(Long id) {
|
||||
return userRepository.findById(id);
|
||||
}
|
||||
|
||||
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
|
||||
public Mono<User> setPassword(User user, String newPassword) {
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> setActive(User user, boolean active) {
|
||||
user.setActive(active);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
public Mono<User> 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());
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:}
|
||||
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
||||
# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@<first domain>)
|
||||
mailserver.admins=${MAIL_ADMINS:}
|
||||
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
|
||||
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||
@@ -47,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();
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class AuthenticationControllerTest {
|
||||
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
||||
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||
mock(MailDomains.class), false, guard);
|
||||
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
|
||||
|
||||
private final User alice = new User();
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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);
|
||||
});
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
|
||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models';
|
||||
|
||||
/** The mail server REST API (same origin, /api). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -17,6 +17,24 @@ export class ApiService {
|
||||
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
// mailboxes — administrators only
|
||||
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
|
||||
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
|
||||
}
|
||||
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// folders
|
||||
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
||||
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,6 +53,26 @@ export interface Profile {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
admin?: boolean; // may manage mailboxes (Settings → Mailboxes)
|
||||
}
|
||||
|
||||
/** MailboxAdminController.MailboxView */
|
||||
export interface Mailbox {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
active: boolean;
|
||||
admin: boolean;
|
||||
trap: boolean; // what it sends stays in its own INBOX, nothing leaves the server (staging@)
|
||||
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 {
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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)' }}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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,
|
||||
// ячейка сетки настроек — сам элемент компонента: на две колонки
|
||||
host: { class: 'block xl:col-span-2' },
|
||||
template: `
|
||||
<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>
|
||||
<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">@</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> }
|
||||
@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)"
|
||||
[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))
|
||||
});
|
||||
}
|
||||
|
||||
/** 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(),
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
copy(text: string): void {
|
||||
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
|
||||
import { errorText, formatBytes } from '../../core/models';
|
||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
import { MailboxesSection } from './mailboxes.section';
|
||||
|
||||
/** Profile name, password, storage, look. */
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [DatePipe, FormsModule, MatIconModule],
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
@@ -41,11 +42,23 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<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>
|
||||
@@ -97,6 +110,10 @@ import { ToastService } from '../../ui/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (state.profile()?.admin) {
|
||||
<app-mailboxes-section />
|
||||
}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
@@ -112,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();
|
||||
|
||||
Reference in New Issue
Block a user