Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44c8e17a27 | ||
|
|
aedda06f2e | ||
|
|
91bb8b9b2d | ||
|
|
18ee4820c1 | ||
|
|
59e2c11d6a | ||
|
|
84ae0b6c7d | ||
|
|
0d0f4800f0 | ||
|
|
b009af972a | ||
|
|
100ab741f3 | ||
|
|
bdca471b97 | ||
|
|
639ab0a6e9 | ||
|
|
f1d66d4c7d | ||
|
|
f9b929ac7a | ||
|
|
4ad2e44e9b |
@@ -2,7 +2,23 @@
|
|||||||
|
|
||||||
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
|
Почтовый сервер на **Spring Boot WebFlux + R2DBC (PostgreSQL) + Angular**: свой SMTP-сервер на Reactor Netty, веб-почта, очередь отправки, DKIM/SPF/DMARC.
|
||||||
|
|
||||||
Назначение — наши личные цели (v0.2.3, раздел 75 чек-листа Jambotron): тестовый проект на Jambotron, ловушка почты для dev/staging. Для писем пользователям Jambotron (регистрация, сброс пароля) остаётся Resend.
|
Назначение — наша настоящая почта **https://mail.jambocheck.com** (prod Jambotron, системная ВМ, адрес `103.109.235.154`) и тестовый проект на Jambotron.
|
||||||
|
|
||||||
|
## Забыл пароль — где взять
|
||||||
|
|
||||||
|
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**.
|
||||||
|
|
||||||
|
**Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
|
||||||
|
|
||||||
|
Пароли остальных ящиков (`staging@`, `no-reply@`, `monitor@`) — в репозитории Jambotron: `INFRASTRUCTURE/SECRETS.md`, раздел «Почта mail.jambocheck.com».
|
||||||
|
|
||||||
|
| Ситуация | Что делать |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Пароль `admin@` ещё не меняли | файл `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет; лежит в томе `data`, передеплой его не стирает). С компьютера: `ssh root@103.109.235.150`, затем `incus exec --project jambotron-prod incust-prod-react-mail-server -- docker exec jambotron-109cf3bb-mail-1 cat /app/data/initial-passwords.txt`. В логе `mail` строка `Mailbox created…` есть **только до первого передеплоя** |
|
||||||
|
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
|
||||||
|
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
|
||||||
|
|
||||||
|
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
|
||||||
|
|
||||||
## Как устроено
|
## Как устроено
|
||||||
|
|
||||||
@@ -68,6 +84,7 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
|
|||||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||||
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||||
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
||||||
|
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
|
||||||
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
||||||
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
||||||
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||||
|
import org.springframework.context.SmartLifecycle;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||||
|
import reactor.netty.DisposableServer;
|
||||||
|
import reactor.netty.http.server.HttpServer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
|
||||||
|
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
|
||||||
|
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
|
||||||
|
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class HttpsConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||||
|
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
|
||||||
|
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.https.port}") int port) {
|
||||||
|
return new HttpsServer(handler, tls, host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HttpsServer implements SmartLifecycle {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
|
||||||
|
|
||||||
|
private final HttpHandler handler;
|
||||||
|
private final MailServerConfig.SmtpTlsContext tls;
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private volatile DisposableServer server;
|
||||||
|
|
||||||
|
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
|
||||||
|
this.handler = handler;
|
||||||
|
this.tls = tls;
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
server = HttpServer.create()
|
||||||
|
.host(host)
|
||||||
|
.port(port)
|
||||||
|
.secure(spec -> spec.sslContext(tls.context()))
|
||||||
|
// HSTS: Spring Security adds it for HTTPS requests by itself
|
||||||
|
.handle(new ReactorHttpHandlerAdapter(handler))
|
||||||
|
.bindNow(Duration.ofSeconds(30));
|
||||||
|
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
DisposableServer current = server;
|
||||||
|
if (current != null) {
|
||||||
|
current.disposeNow(Duration.ofSeconds(10));
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return server != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int port() {
|
||||||
|
return server == null ? -1 : server.port();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ package com.mailserver.controller;
|
|||||||
import com.mailserver.config.MailAdmins;
|
import com.mailserver.config.MailAdmins;
|
||||||
import com.mailserver.config.MailDomains;
|
import com.mailserver.config.MailDomains;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.dto.LoginResponse;
|
||||||
import com.mailserver.service.MailService;
|
import com.mailserver.service.MailService;
|
||||||
import com.mailserver.service.UserService;
|
import com.mailserver.service.UserService;
|
||||||
|
import com.mailserver.util.JwtUtils;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -66,8 +68,10 @@ public class MailboxAdminController {
|
|||||||
private final MailService mail;
|
private final MailService mail;
|
||||||
private final MailDomains domains;
|
private final MailDomains domains;
|
||||||
private final MailAdmins admins;
|
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.users = users;
|
||||||
this.mail = mail;
|
this.mail = mail;
|
||||||
this.domains = domains;
|
this.domains = domains;
|
||||||
@@ -123,6 +127,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. */
|
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
|
||||||
@PutMapping("/{id}/active")
|
@PutMapping("/{id}/active")
|
||||||
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
|
|||||||
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
||||||
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
||||||
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
||||||
|
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
|
||||||
|
mailserver.https.port=${HTTPS_PORT:0}
|
||||||
mailserver.smtps.port=${SMTPS_PORT:0}
|
mailserver.smtps.port=${SMTPS_PORT:0}
|
||||||
|
|
||||||
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.smtp.SmtpTls;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
|
||||||
|
class HttpsConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
|
||||||
|
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
|
||||||
|
HttpHandler handler = (request, response) -> {
|
||||||
|
response.setStatusCode(HttpStatus.OK);
|
||||||
|
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
|
||||||
|
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
|
||||||
|
};
|
||||||
|
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
|
||||||
|
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
|
||||||
|
server.start();
|
||||||
|
try {
|
||||||
|
assertThat(server.isRunning()).isTrue();
|
||||||
|
String body = HttpClient.create()
|
||||||
|
.secure(spec -> spec.sslContext(trustAll))
|
||||||
|
.get().uri("https://127.0.0.1:" + server.port() + "/")
|
||||||
|
.responseContent().aggregate().asString().block();
|
||||||
|
assertThat(body).isEqualTo("hello https");
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
assertThat(server.isRunning()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ class MailboxAdminControllerTest {
|
|||||||
private final MailService mail = mock(MailService.class);
|
private final MailService mail = mock(MailService.class);
|
||||||
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
||||||
private final MailAdmins admins = new MailAdmins("", "", domains);
|
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 admin = user(1L, "admin@jambocheck.com");
|
||||||
private final User alice = user(2L, "alice@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.all()).thenReturn(Flux.just(alice, admin));
|
||||||
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
||||||
when(users.byId(2L)).thenReturn(Mono.just(alice));
|
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.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.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||||
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
@@ -105,6 +107,18 @@ class MailboxAdminControllerTest {
|
|||||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
.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) {
|
private MailboxAdminController.Created create(String me, String email, String password) {
|
||||||
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
|
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
# 465 SMTPS: mail programs, implicit TLS
|
# 465 SMTPS: mail programs, implicit TLS
|
||||||
# 993 IMAPS: mail programs, implicit TLS
|
# 993 IMAPS: mail programs, implicit TLS
|
||||||
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
|
# 80 only for Let's Encrypt (HTTP-01) — service "acme"
|
||||||
# The web client + API (service "mail", port 8080) goes through Jambotron's Caddy under ANOTHER name
|
# 443 web client + API over HTTPS, same Let's Encrypt certificate (acme): https://mail.jambocheck.com
|
||||||
|
# The web client + API can also go through Jambotron's Caddy (plain port 8080) under ANOTHER name
|
||||||
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
|
# (webmail.<domain>): the name MAIL_HOSTNAME points at the VM's public address, not at Caddy.
|
||||||
#
|
#
|
||||||
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
|
# DNS before the first start (the certificate is issued over HTTP on MAIL_HOSTNAME):
|
||||||
@@ -121,8 +122,9 @@ services:
|
|||||||
- "587:3025"
|
- "587:3025"
|
||||||
- "465:3465"
|
- "465:3465"
|
||||||
- "993:3993"
|
- "993:3993"
|
||||||
|
- "443:8443" # web client + API over HTTPS: https://mail.jambocheck.com
|
||||||
expose:
|
expose:
|
||||||
- "8080" # web client + API → webmail.<domain> in Jambotron
|
- "8080" # plain HTTP, for a reverse proxy inside the network (not needed on the public address)
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||||
@@ -132,6 +134,7 @@ services:
|
|||||||
MAIL_TLS_KEY: /tls/key.pem
|
MAIL_TLS_KEY: /tls/key.pem
|
||||||
SMTPS_PORT: "3465"
|
SMTPS_PORT: "3465"
|
||||||
IMAPS_PORT: "3993"
|
IMAPS_PORT: "3993"
|
||||||
|
HTTPS_PORT: "8443"
|
||||||
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
|
DKIM_DOMAIN: ${MAIL_DOMAINS:-jambocheck.com}
|
||||||
DKIM_SELECTOR: mail
|
DKIM_SELECTOR: mail
|
||||||
DKIM_KEYS_DIR: /app/data/dkim-keys
|
DKIM_KEYS_DIR: /app/data/dkim-keys
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export class ApiService {
|
|||||||
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||||
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
||||||
}
|
}
|
||||||
|
openMailbox(id: number): Observable<{ token: string; email: string }> {
|
||||||
|
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
|
||||||
|
}
|
||||||
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export class AuthService {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
|
||||||
readonly email = signal<string | null>(read('email'));
|
readonly email = signal<string | null>(read('email'));
|
||||||
|
/** Administrator who opened this mailbox (Settings → MAILBOXES → Open), or null */
|
||||||
|
readonly openedBy = signal<string | null>(read('adminEmail'));
|
||||||
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
|
readonly loggedIn = computed(() => this.email() !== null && read('token') !== null);
|
||||||
|
|
||||||
login(email: string, password: string): Observable<TokenResponse> {
|
login(email: string, password: string): Observable<TokenResponse> {
|
||||||
@@ -24,6 +26,32 @@ export class AuthService {
|
|||||||
|
|
||||||
token(): string | null { return read('token'); }
|
token(): string | null { return read('token'); }
|
||||||
|
|
||||||
|
/** Administrator opens another mailbox: its session, the admin session kept to come back. */
|
||||||
|
openAs(response: TokenResponse): void {
|
||||||
|
const adminToken = read('token');
|
||||||
|
const adminEmail = read('email');
|
||||||
|
try {
|
||||||
|
if (adminToken && adminEmail && !read('adminToken')) {
|
||||||
|
localStorage.setItem('adminToken', adminToken);
|
||||||
|
localStorage.setItem('adminEmail', adminEmail);
|
||||||
|
}
|
||||||
|
} catch { /* storage blocked */ }
|
||||||
|
this.store(response);
|
||||||
|
this.openedBy.set(read('adminEmail'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to the administrator's own mailbox. */
|
||||||
|
backToAdmin(): void {
|
||||||
|
const token = read('adminToken');
|
||||||
|
const email = read('adminEmail');
|
||||||
|
try {
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminEmail');
|
||||||
|
} catch { /* storage blocked */ }
|
||||||
|
this.openedBy.set(null);
|
||||||
|
if (token && email) { this.store({ token, email }); } else { this.clear(); }
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.clear();
|
this.clear();
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
@@ -33,8 +61,11 @@ export class AuthService {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('email');
|
localStorage.removeItem('email');
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
|
localStorage.removeItem('adminEmail');
|
||||||
} catch { /* storage blocked */ }
|
} catch { /* storage blocked */ }
|
||||||
this.email.set(null);
|
this.email.set(null);
|
||||||
|
this.openedBy.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private store(response: TokenResponse): void {
|
private store(response: TokenResponse): void {
|
||||||
|
|||||||
@@ -49,13 +49,21 @@ const NAME = /^[a-z0-9]([a-z0-9._-]{0,62}[a-z0-9])?$/;
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="synth-label" for="password">Password (8+ characters)</label>
|
<label class="synth-label" for="password">Password (8+ characters)</label>
|
||||||
<input id="password" name="password" type="password" class="synth-input" [ngModel]="password()"
|
<div class="relative"><input id="password" name="password" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password()"
|
||||||
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
|
(ngModelChange)="password.set($event)" required minlength="8" autocomplete="new-password">
|
||||||
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||||
|
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||||
|
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="synth-label" for="password2">Password again</label>
|
<label class="synth-label" for="password2">Password again</label>
|
||||||
<input id="password2" name="password2" type="password" class="synth-input" [ngModel]="password2()"
|
<div class="relative"><input id="password2" name="password2" [type]="show() ? 'text' : 'password'" class="synth-input pr-10" [ngModel]="password2()"
|
||||||
(ngModelChange)="password2.set($event)" required autocomplete="new-password">
|
(ngModelChange)="password2.set($event)" required autocomplete="new-password">
|
||||||
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||||
|
[attr.aria-label]="show() ? 'Hide password' : 'Show password'" (click)="show.set(!show())">
|
||||||
|
<mat-icon>{{ show() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button></div>
|
||||||
</div>
|
</div>
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2" role="alert">
|
||||||
@@ -79,6 +87,7 @@ export class RegisterPage {
|
|||||||
|
|
||||||
readonly loaded = signal(false);
|
readonly loaded = signal(false);
|
||||||
readonly enabled = signal(false);
|
readonly enabled = signal(false);
|
||||||
|
readonly show = signal(false);
|
||||||
readonly domains = signal<string[]>([]);
|
readonly domains = signal<string[]>([]);
|
||||||
readonly domain = signal('');
|
readonly domain = signal('');
|
||||||
readonly local = signal('');
|
readonly local = signal('');
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template jtGridCell="subject" let-row>
|
<ng-template jtGridCell="subject" let-row>
|
||||||
<a [routerLink]="link(row)" class="block max-w-[560px] group">
|
<a [routerLink]="link(row)" class="block max-w-[520px] group">
|
||||||
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
<span class="truncate block group-hover:text-cyan-300" [class.font-bold]="!row.read" [class.text-white]="!row.read">
|
||||||
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
|
@if (!row.read) { <span class="inline-block w-2 h-2 rounded-full bg-pink-500 mr-1.5 align-middle shadow-[0_0_8px_#ff007f]" aria-label="Unread"></span> }
|
||||||
{{ row.subject || '(no subject)' }}
|
{{ row.subject || '(no subject)' }}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ApiService } from '../../core/api.service';
|
|||||||
import { MailStateService } from '../../core/mail-state.service';
|
import { MailStateService } from '../../core/mail-state.service';
|
||||||
import { Mailbox, errorText, formatBytes } from '../../core/models';
|
import { Mailbox, errorText, formatBytes } from '../../core/models';
|
||||||
import { ToastService } from '../../ui/toast.service';
|
import { ToastService } from '../../ui/toast.service';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
|
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
|
||||||
@Component({
|
@Component({
|
||||||
@@ -19,11 +20,26 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
|
|
||||||
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
|
<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><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">@</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>
|
<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>
|
<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><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>
|
<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>
|
</form>
|
||||||
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||||
@@ -46,6 +62,10 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
<span class="text-sm text-slate-400">{{ m.displayName }}</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.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
|
||||||
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||||
|
@if (m.email !== me()) {
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="open(m)" [attr.aria-label]="'Open ' + m.email" title="Open mailbox">
|
||||||
|
<mat-icon>mark_email_read</mat-icon> OPEN</button>
|
||||||
|
}
|
||||||
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
<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()) {
|
@if (m.email !== me()) {
|
||||||
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
|
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
|
||||||
@@ -62,19 +82,27 @@ export class MailboxesSection {
|
|||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
private state = inject(MailStateService);
|
private state = inject(MailStateService);
|
||||||
private toasts = inject(ToastService);
|
private toasts = inject(ToastService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
|
|
||||||
readonly mailboxes = signal<Mailbox[]>([]);
|
readonly mailboxes = signal<Mailbox[]>([]);
|
||||||
readonly secret = signal<{ email: string; password: string } | null>(null);
|
readonly secret = signal<{ email: string; password: string } | null>(null);
|
||||||
readonly error = signal('');
|
readonly error = signal('');
|
||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
readonly me = computed(() => this.state.profile()?.email ?? '');
|
readonly me = computed(() => this.state.profile()?.email ?? '');
|
||||||
readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com');
|
readonly showPw = signal(false);
|
||||||
|
readonly local = signal('');
|
||||||
email = '';
|
// Домены сервера (/api/config); пока не пришли — домен своего ящика
|
||||||
|
readonly domains = signal<string[]>([]);
|
||||||
|
readonly domain = signal('');
|
||||||
name = '';
|
name = '';
|
||||||
password = '';
|
password = '';
|
||||||
|
|
||||||
constructor() {
|
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();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,14 +115,16 @@ export class MailboxesSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
const email = this.email.trim();
|
const local = this.local().replace(/@.*$/, '');
|
||||||
if (!email) { this.error.set('Enter an address'); return; }
|
if (!local) { this.error.set('Enter an address'); return; }
|
||||||
|
const email = local + '@' + this.domain();
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
this.busy.set(true);
|
this.busy.set(true);
|
||||||
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
||||||
next: created => {
|
next: created => {
|
||||||
this.busy.set(false);
|
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 }); }
|
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
|
||||||
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
||||||
this.load();
|
this.load();
|
||||||
@@ -111,6 +141,14 @@ 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))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toggle(m: Mailbox): void {
|
toggle(m: Mailbox): void {
|
||||||
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
||||||
next: () => this.load(),
|
next: () => this.load(),
|
||||||
|
|||||||
@@ -42,11 +42,23 @@ import { MailboxesSection } from './mailboxes.section';
|
|||||||
<form (ngSubmit)="changePassword()" class="space-y-3">
|
<form (ngSubmit)="changePassword()" class="space-y-3">
|
||||||
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
|
<input type="email" name="username" class="sr-only" [value]="state.profile()?.email ?? ''" autocomplete="username" tabindex="-1" aria-hidden="true">
|
||||||
<div><label class="synth-label" for="pw-current">Current password</label>
|
<div><label class="synth-label" for="pw-current">Current password</label>
|
||||||
<input id="pw-current" name="current" type="password" class="synth-input" [(ngModel)]="current" autocomplete="current-password"></div>
|
<div class="relative"><input id="pw-current" name="current" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="current" autocomplete="current-password">
|
||||||
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||||
|
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||||
|
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button></div></div>
|
||||||
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
|
<div><label class="synth-label" for="pw-new">New password (8+ characters)</label>
|
||||||
<input id="pw-new" name="next" type="password" class="synth-input" [(ngModel)]="next" autocomplete="new-password"></div>
|
<div class="relative"><input id="pw-new" name="next" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="next" autocomplete="new-password">
|
||||||
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||||
|
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||||
|
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button></div></div>
|
||||||
<div><label class="synth-label" for="pw-again">New password again</label>
|
<div><label class="synth-label" for="pw-again">New password again</label>
|
||||||
<input id="pw-again" name="again" type="password" class="synth-input" [(ngModel)]="again" autocomplete="new-password"></div>
|
<div class="relative"><input id="pw-again" name="again" [type]="showPw() ? 'text' : 'password'" class="synth-input pr-10" [(ngModel)]="again" autocomplete="new-password">
|
||||||
|
<button type="button" class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-cyan-300"
|
||||||
|
[attr.aria-label]="showPw() ? 'Hide password' : 'Show password'" (click)="showPw.set(!showPw())">
|
||||||
|
<mat-icon>{{ showPw() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||||
|
</button></div></div>
|
||||||
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
|
@if (passwordError()) { <p class="text-sm text-pink-400" role="alert">{{ passwordError() }}</p> }
|
||||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
|
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>lock_reset</mat-icon> CHANGE PASSWORD</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -117,6 +129,7 @@ export class SettingsPage {
|
|||||||
next = '';
|
next = '';
|
||||||
again = '';
|
again = '';
|
||||||
readonly passwordError = signal('');
|
readonly passwordError = signal('');
|
||||||
|
readonly showPw = signal(false);
|
||||||
|
|
||||||
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
|
readonly used = computed(() => formatBytes(this.state.profile()?.usedBytes));
|
||||||
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
|
readonly quota = computed(() => formatBytes(this.state.profile()?.quotaBytes));
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
@if (auth.openedBy(); as admin) {
|
||||||
|
<div class="bg-pink-600/90 text-white text-sm font-rajdhani px-4 py-2 flex flex-wrap items-center justify-center gap-3" role="status">
|
||||||
|
<mat-icon class="!text-base">visibility</mat-icon>
|
||||||
|
<span>You are in mailbox <b class="font-mono">{{ auth.email() }}</b> as administrator</span>
|
||||||
|
<button type="button" class="ghost-btn !py-1 !border-white !text-white" (click)="backToAdmin()">
|
||||||
|
<mat-icon>arrow_back</mat-icon> Back to {{ admin }}</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
||||||
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
|
<nav class="glass-panel static p-4 h-fit lg:sticky lg:top-24 lg:block" [class.hidden]="!menuOpen()"
|
||||||
aria-label="Mail folders">
|
aria-label="Mail folders">
|
||||||
@@ -247,6 +256,11 @@ export class ShellComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
backToAdmin(): void {
|
||||||
|
this.auth.backToAdmin();
|
||||||
|
location.assign('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.state.reset();
|
this.state.reset();
|
||||||
this.auth.logout();
|
this.auth.logout();
|
||||||
|
|||||||
Reference in New Issue
Block a user