Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
639ab0a6e9 | ||
|
|
f1d66d4c7d | ||
|
|
f9b929ac7a | ||
|
|
4ad2e44e9b | ||
|
|
2149c29a32 |
@@ -2,7 +2,19 @@
|
|||||||
|
|
||||||
Почтовый сервер на **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@` ещё не меняли | Jambotron prod → проект почты → **Сервисы → `mail` → Логи**, строка `Mailbox created: admin@jambocheck.com — password: …`. Та же строка — в файле `/app/data/initial-passwords.txt` **внутри контейнера** `mail` на ВМ почты (в этом репозитории его нет) |
|
||||||
|
| Пароль `admin@` сменили и забыли | новый пароль прямо в базе почты, ящик и письма остаются — попросить α/Ω; команда — Jambotron, `INFRASTRUCTURE/test-stands.md`, раздел «Почта mail.jambocheck.com» |
|
||||||
|
| Забыт пароль другого ящика (`staging@`, `no-reply@`, `monitor@`, свой) | войти под `admin@` → Настройки → MAILBOXES → ключик у ящика → новый пароль (показывается один раз) и вписать его туда, где ящик настроен |
|
||||||
|
|
||||||
|
Всё о prod-почте (порты, DNS, кто через неё шлёт) — Jambotron, `INFRASTRUCTURE/test-stands.md`.
|
||||||
|
|
||||||
## Как устроено
|
## Как устроено
|
||||||
|
|
||||||
@@ -67,6 +79,8 @@ API: `GET /api/config` (без входа), `/api/me`, `/api/me/password`, `/api
|
|||||||
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
| `MAIL_DOMAINS` | наши домены, через запятую | `localhost` |
|
||||||
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
| `MAIL_HOSTNAME` | имя сервера в SMTP | `localhost` |
|
||||||
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
| `MAIL_ADMIN_EMAIL`, `MAIL_ADMIN_PASSWORD` | первый ящик на пустой базе; без пароля — случайный (в `MAIL_PASSWORDS_FILE` или один раз в логе) | `admin@<первый домен>` |
|
||||||
|
| `MAIL_ADMINS` | кто управляет ящиками (Настройки → Mailboxes: создать, новый пароль, выключить), через запятую | первый ящик (`MAIL_ADMIN_EMAIL` или `admin@<первый домен>`) |
|
||||||
|
| `HTTPS_PORT` | веб-клиент и API по HTTPS тем же сертификатом, что SMTP/IMAP (в `docker-compose.public.yml` — 8443 → 443: `https://mail.<домен>`); 0 — только HTTP на `server.port` | 0 |
|
||||||
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
| `MAIL_PASSWORDS_FILE` | файл (права 600), куда дописываются случайные пароли созданных ящиков: первого и ловушки | — (в образе `/app/data/initial-passwords.txt`) |
|
||||||
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
| `MAIL_REGISTRATION` | открыть регистрацию | `false` |
|
||||||
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
| `MAIL_CATCH_ALL` | ящик-ловушка: письма на адреса без своего ящика здесь (и на чужие домены) — сюда, наружу ничего; ящик создаётся при старте | — |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||||
|
import org.springframework.context.SmartLifecycle;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||||
|
import reactor.netty.DisposableServer;
|
||||||
|
import reactor.netty.http.server.HttpServer;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web client and API over HTTPS on the mail host itself (mailserver.https.port, in public compose 8443 →
|
||||||
|
* published 443): https://mail.jambocheck.com opens the web mail, no second name through Jambotron's Caddy.
|
||||||
|
* Same certificate as SMTP/IMAP (Let's Encrypt from the acme service, reloaded when renewed). The plain HTTP port
|
||||||
|
* (8080) stays for a reverse proxy inside the network (staging: domain in Jambotron).
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class HttpsConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnExpression("${mailserver.https.port:0} > 0 and ${mailserver.tls.enabled:true}")
|
||||||
|
public HttpsServer httpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls,
|
||||||
|
@Value("${mailserver.smtp.host:0.0.0.0}") String host,
|
||||||
|
@Value("${mailserver.https.port}") int port) {
|
||||||
|
return new HttpsServer(handler, tls, host, port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HttpsServer implements SmartLifecycle {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(HttpsServer.class);
|
||||||
|
|
||||||
|
private final HttpHandler handler;
|
||||||
|
private final MailServerConfig.SmtpTlsContext tls;
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private volatile DisposableServer server;
|
||||||
|
|
||||||
|
HttpsServer(HttpHandler handler, MailServerConfig.SmtpTlsContext tls, String host, int port) {
|
||||||
|
this.handler = handler;
|
||||||
|
this.tls = tls;
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start() {
|
||||||
|
server = HttpServer.create()
|
||||||
|
.host(host)
|
||||||
|
.port(port)
|
||||||
|
.secure(spec -> spec.sslContext(tls.context()))
|
||||||
|
// HSTS: Spring Security adds it for HTTPS requests by itself
|
||||||
|
.handle(new ReactorHttpHandlerAdapter(handler))
|
||||||
|
.bindNow(Duration.ofSeconds(30));
|
||||||
|
log.info("HTTPS (web client + API) listening on {}:{}", host, server.port());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
DisposableServer current = server;
|
||||||
|
if (current != null) {
|
||||||
|
current.disposeNow(Duration.ofSeconds(10));
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isRunning() {
|
||||||
|
return server != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int port() {
|
||||||
|
return server == null ? -1 : server.port();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
|
||||||
|
* mailbox — {@code mailserver.admin.email}, or admin@<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;
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
import com.mailserver.config.MailDomains;
|
import com.mailserver.config.MailDomains;
|
||||||
import com.mailserver.domain.User;
|
import com.mailserver.domain.User;
|
||||||
import com.mailserver.dto.LoginRequest;
|
import com.mailserver.dto.LoginRequest;
|
||||||
@@ -55,11 +56,13 @@ public class AuthenticationController {
|
|||||||
private final MailDomains domains;
|
private final MailDomains domains;
|
||||||
private final boolean registrationEnabled;
|
private final boolean registrationEnabled;
|
||||||
private final LoginGuard guard;
|
private final LoginGuard guard;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
|
||||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
||||||
LoginGuard guard) {
|
LoginGuard guard, MailAdmins admins) {
|
||||||
this.guard = guard;
|
this.guard = guard;
|
||||||
|
this.admins = admins;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.mailService = mailService;
|
this.mailService = mailService;
|
||||||
this.jwtUtils = jwtUtils;
|
this.jwtUtils = jwtUtils;
|
||||||
@@ -179,7 +182,8 @@ public class AuthenticationController {
|
|||||||
private Mono<ProfileResponse> profile(User user) {
|
private Mono<ProfileResponse> profile(User user) {
|
||||||
return mailService.usedBytes(user.getId())
|
return mailService.usedBytes(user.getId())
|
||||||
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
.map(used -> new ProfileResponse(user.getEmail(), user.getDisplayName(), used,
|
||||||
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt()));
|
user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(), user.getCreatedAt(),
|
||||||
|
admins.isAdmin(user.getEmail())));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<User> user(Mono<Authentication> auth) {
|
private Mono<User> user(Mono<Authentication> auth) {
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@,
|
||||||
|
* support@ — registration stays closed), new password, switch off. A generated password is returned once, in the
|
||||||
|
* answer — it is not logged.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/mailboxes")
|
||||||
|
public class MailboxAdminController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||||
|
private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._+-]{0,62}[a-z0-9])?");
|
||||||
|
private static final int MIN_PASSWORD = 10;
|
||||||
|
|
||||||
|
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin,
|
||||||
|
long usedBytes, long quotaBytes, Instant createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password empty — the server generates one and returns it once */
|
||||||
|
public record CreateRequest(@NotBlank @Size(max = 254) String email, @Size(max = 100) String displayName,
|
||||||
|
@Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PasswordRequest(@Size(max = 200) String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ActiveRequest(boolean active) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password the generated password, or null when the administrator chose it */
|
||||||
|
public record Created(MailboxView mailbox, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final UserService users;
|
||||||
|
private final MailService mail;
|
||||||
|
private final MailDomains domains;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
|
||||||
|
public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins) {
|
||||||
|
this.users = users;
|
||||||
|
this.mail = mail;
|
||||||
|
this.domains = domains;
|
||||||
|
this.admins = admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<MailboxView> list(Mono<Authentication> auth) {
|
||||||
|
return admin(auth).thenMany(users.all())
|
||||||
|
.concatMap(this::view)
|
||||||
|
.sort(Comparator.comparing(MailboxView::email));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||||
|
return admin(auth).then(body).flatMap(request -> {
|
||||||
|
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
|
||||||
|
int at = email.lastIndexOf('@');
|
||||||
|
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
|
||||||
|
return Mono.error(bad("Not a mailbox address: " + email));
|
||||||
|
}
|
||||||
|
if (!domains.isLocal(email)) {
|
||||||
|
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
|
||||||
|
}
|
||||||
|
String chosen = request.password() == null ? "" : request.password();
|
||||||
|
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||||
|
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||||
|
}
|
||||||
|
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||||
|
return users.register(email, password, request.displayName())
|
||||||
|
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
|
||||||
|
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New password: the given one, or a generated one returned once. */
|
||||||
|
@PostMapping("/{id}/password")
|
||||||
|
public Mono<Created> password(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<PasswordRequest> body) {
|
||||||
|
return admin(auth).then(body.defaultIfEmpty(new PasswordRequest(null))).flatMap(request -> {
|
||||||
|
String chosen = request.password() == null ? "" : request.password();
|
||||||
|
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||||
|
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||||
|
}
|
||||||
|
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||||
|
return mailbox(id)
|
||||||
|
.flatMap(user -> users.setPassword(user, password))
|
||||||
|
.doOnNext(user -> log.info("Password of {} set by administrator", user.getEmail()))
|
||||||
|
.flatMap(this::view)
|
||||||
|
.map(view -> new Created(view, chosen.isEmpty() ? password : null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switched off: no login (web, SMTP, IMAP); mail to it is still received. */
|
||||||
|
@PutMapping("/{id}/active")
|
||||||
|
public Mono<MailboxView> active(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<ActiveRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||||
|
if (!request.active() && user.getEmail().equalsIgnoreCase(me)) {
|
||||||
|
return Mono.error(bad("You can not switch off your own mailbox"));
|
||||||
|
}
|
||||||
|
log.info("Mailbox {} {} by administrator", user.getEmail(), request.active() ? "switched on" : "switched off");
|
||||||
|
return users.setActive(user, request.active());
|
||||||
|
}))).flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<String> admin(Mono<Authentication> auth) {
|
||||||
|
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
|
||||||
|
.map(Authentication::getName)
|
||||||
|
.filter(admins::isAdmin)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "Only for mail administrators")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<User> mailbox(Long id) {
|
||||||
|
return users.byId(id).switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "Mailbox not found")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<MailboxView> view(User user) {
|
||||||
|
return mail.usedBytes(user.getId()).defaultIfEmpty(0L)
|
||||||
|
.map(used -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||||
|
admins.isAdmin(user.getEmail()), used, user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||||
|
user.getCreatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseStatusException bad(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String randomPassword() {
|
||||||
|
byte[] bytes = new byte[18];
|
||||||
|
new SecureRandom().nextBytes(bytes);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,7 @@ package com.mailserver.dto;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt) {
|
/** @param admin may manage mailboxes ({@link com.mailserver.config.MailAdmins}) */
|
||||||
|
public record ProfileResponse(String email, String displayName, long usedBytes, long quotaBytes, Instant createdAt,
|
||||||
|
boolean admin) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,25 @@ public class UserService {
|
|||||||
return userRepository.save(user).then();
|
return userRepository.save(user).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Flux<User> all() {
|
||||||
|
return userRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> byId(Long id) {
|
||||||
|
return userRepository.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Administrator sets a new password (Settings → Mailboxes): no current password needed. */
|
||||||
|
public Mono<User> setPassword(User user, String newPassword) {
|
||||||
|
user.setPassword(passwordEncoder.encode(newPassword));
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<User> setActive(User user, boolean active) {
|
||||||
|
user.setActive(active);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<User> updateDisplayName(User user, String displayName) {
|
public Mono<User> updateDisplayName(User user, String displayName) {
|
||||||
user.setDisplayName(displayName.trim());
|
user.setDisplayName(displayName.trim());
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ mailserver.catch-all=${MAIL_CATCH_ALL:}
|
|||||||
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
mailserver.registration.enabled=${MAIL_REGISTRATION:false}
|
||||||
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
mailserver.admin.email=${MAIL_ADMIN_EMAIL:}
|
||||||
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
|
||||||
|
# Who manages mailboxes (Settings → Mailboxes), comma separated; empty — the first mailbox (admin.email or admin@<first domain>)
|
||||||
|
mailserver.admins=${MAIL_ADMINS:}
|
||||||
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
|
# Generated passwords of created mailboxes (first admin, catch-all) are appended here (mode 600); empty — printed to the log once
|
||||||
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
||||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||||
@@ -47,6 +49,8 @@ mailserver.tls.enabled=${MAIL_TLS:true}
|
|||||||
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
mailserver.tls.cert-file=${MAIL_TLS_CERT:}
|
||||||
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
mailserver.tls.key-file=${MAIL_TLS_KEY:}
|
||||||
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
mailserver.tls.dir=${MAIL_TLS_DIR:./tls}
|
||||||
|
# Web client + API over HTTPS with the same certificate (public compose: 8443 → 443); 0 — only plain HTTP (server.port)
|
||||||
|
mailserver.https.port=${HTTPS_PORT:0}
|
||||||
mailserver.smtps.port=${SMTPS_PORT:0}
|
mailserver.smtps.port=${SMTPS_PORT:0}
|
||||||
|
|
||||||
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
# Outbound: off | mx (straight to recipients' MX, port 25) | smarthost (via a provider, e.g. Resend)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import com.mailserver.smtp.SmtpTls;
|
||||||
|
import io.netty.handler.ssl.SslContextBuilder;
|
||||||
|
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/** https://mail.jambocheck.com: the web client over HTTPS with the mail certificate. */
|
||||||
|
class HttpsConfigTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void servesTheWebAppOverTls(@TempDir Path dir) throws Exception {
|
||||||
|
MailServerConfig.SmtpTlsContext tls = new MailServerConfig.SmtpTlsContext(SmtpTls.serverContext("", "", dir, "localhost"));
|
||||||
|
HttpHandler handler = (request, response) -> {
|
||||||
|
response.setStatusCode(HttpStatus.OK);
|
||||||
|
return response.writeWith(Mono.just(DefaultDataBufferFactory.sharedInstance
|
||||||
|
.wrap(("hello " + request.getURI().getScheme()).getBytes(StandardCharsets.UTF_8))));
|
||||||
|
};
|
||||||
|
io.netty.handler.ssl.SslContext trustAll = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build();
|
||||||
|
HttpsConfig.HttpsServer server = new HttpsConfig.HttpsServer(handler, tls, "127.0.0.1", 0);
|
||||||
|
server.start();
|
||||||
|
try {
|
||||||
|
assertThat(server.isRunning()).isTrue();
|
||||||
|
String body = HttpClient.create()
|
||||||
|
.secure(spec -> spec.sslContext(trustAll))
|
||||||
|
.get().uri("https://127.0.0.1:" + server.port() + "/")
|
||||||
|
.responseContent().aggregate().asString().block();
|
||||||
|
assertThat(body).isEqualTo("hello https");
|
||||||
|
} finally {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
assertThat(server.isRunning()).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ class AuthenticationControllerTest {
|
|||||||
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
||||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
||||||
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||||
mock(MailDomains.class), false, guard);
|
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
|
||||||
|
|
||||||
private final User alice = new User();
|
private final User alice = new User();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.config.MailDomains;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.MailService;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/** Settings → Mailboxes: only administrators; mailboxes only in our domains; a generated password comes back once. */
|
||||||
|
class MailboxAdminControllerTest {
|
||||||
|
|
||||||
|
private final UserService users = mock(UserService.class);
|
||||||
|
private final MailService mail = mock(MailService.class);
|
||||||
|
private final MailDomains domains = new MailDomains("jambocheck.com", "");
|
||||||
|
private final MailAdmins admins = new MailAdmins("", "", domains);
|
||||||
|
private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins);
|
||||||
|
|
||||||
|
private final User admin = user(1L, "admin@jambocheck.com");
|
||||||
|
private final User alice = user(2L, "alice@jambocheck.com");
|
||||||
|
|
||||||
|
MailboxAdminControllerTest() {
|
||||||
|
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L));
|
||||||
|
when(users.all()).thenReturn(Flux.just(alice, admin));
|
||||||
|
when(users.byId(1L)).thenReturn(Mono.just(admin));
|
||||||
|
when(users.byId(2L)).thenReturn(Mono.just(alice));
|
||||||
|
when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(3L, i.getArgument(0))));
|
||||||
|
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
|
||||||
|
when(users.setActive(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||||
|
User u = i.getArgument(0);
|
||||||
|
u.setActive(i.getArgument(1));
|
||||||
|
return Mono.just(u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void administratorIsTheFirstMailboxByDefault() {
|
||||||
|
assertThat(admins.isAdmin("ADMIN@jambocheck.com")).isTrue();
|
||||||
|
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
||||||
|
assertThat(new MailAdmins("ops@jambocheck.com, root@jambocheck.com", "admin@jambocheck.com", domains).isAdmin("admin@jambocheck.com")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listsMailboxesSortedForAnAdministratorOnly() {
|
||||||
|
List<MailboxAdminController.MailboxView> list = controller.list(as("admin@jambocheck.com")).collectList().block();
|
||||||
|
assertThat(list).extracting(MailboxAdminController.MailboxView::email).containsExactly("admin@jambocheck.com", "alice@jambocheck.com");
|
||||||
|
assertThat(list.get(0).admin()).isTrue();
|
||||||
|
|
||||||
|
assertThat(status(() -> controller.list(as("alice@jambocheck.com")).collectList().block())).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createsReservedNamesWithAGeneratedPasswordShownOnce() {
|
||||||
|
var created = controller.create(as("admin@jambocheck.com"),
|
||||||
|
Mono.just(new MailboxAdminController.CreateRequest(" No-Reply@JamboCheck.com ", "Jambotron", ""))).block();
|
||||||
|
|
||||||
|
assertThat(created.mailbox().email()).isEqualTo("no-reply@jambocheck.com");
|
||||||
|
assertThat(created.password()).hasSizeGreaterThanOrEqualTo(20);
|
||||||
|
verify(users).register(eq("no-reply@jambocheck.com"), eq(created.password()), eq("Jambotron"));
|
||||||
|
|
||||||
|
var chosen = controller.create(as("admin@jambocheck.com"),
|
||||||
|
Mono.just(new MailboxAdminController.CreateRequest("monitor@jambocheck.com", null, "long-enough-1"))).block();
|
||||||
|
assertThat(chosen.password()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refusesOtherDomainsBadAddressesShortPasswordsAndNonAdmins() {
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "x@gmail.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "bad address@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "x@jambocheck.com", "short"))).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(status(() -> create("alice@jambocheck.com", "x@jambocheck.com", ""))).isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
verify(users, never()).register(anyString(), anyString(), any());
|
||||||
|
|
||||||
|
when(users.register(eq("alice@jambocheck.com"), anyString(), any())).thenReturn(Mono.error(new IllegalArgumentException("exists")));
|
||||||
|
assertThat(status(() -> create("admin@jambocheck.com", "alice@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newPasswordAndSwitchOffButNotYourself() {
|
||||||
|
var reset = controller.password(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.PasswordRequest(null))).block();
|
||||||
|
assertThat(reset.password()).isNotBlank();
|
||||||
|
verify(users).setPassword(alice, reset.password());
|
||||||
|
|
||||||
|
assertThat(controller.active(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block().active()).isFalse();
|
||||||
|
assertThat(status(() -> controller.active(as("admin@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.ActiveRequest(false))).block()))
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MailboxAdminController.Created create(String me, String email, String password) {
|
||||||
|
return controller.create(as(me), Mono.just(new MailboxAdminController.CreateRequest(email, null, password))).block();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<Authentication> as(String email) {
|
||||||
|
return Mono.just(new UsernamePasswordAuthenticationToken(email, null, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpStatus status(Runnable call) {
|
||||||
|
Throwable error = org.assertj.core.api.Assertions.catchThrowable(call::run);
|
||||||
|
assertThat(error).isInstanceOf(ResponseStatusException.class);
|
||||||
|
return HttpStatus.valueOf(((ResponseStatusException) error).getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static User user(Long id, String email) {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(id);
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPassword("hash");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Message, Profile, ServerConfig } from './models';
|
import { Attachment, BlacklistEntry, Contact, Draft, Folder, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models';
|
||||||
|
|
||||||
/** The mail server REST API (same origin, /api). */
|
/** The mail server REST API (same origin, /api). */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -17,6 +17,18 @@ export class ApiService {
|
|||||||
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
return this.http.put('/api/me/password', { currentPassword, newPassword });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mailboxes — administrators only
|
||||||
|
mailboxes(): Observable<Mailbox[]> { return this.http.get<Mailbox[]>('/api/admin/mailboxes'); }
|
||||||
|
createMailbox(email: string, displayName: string, password: string): Observable<MailboxCreated> {
|
||||||
|
return this.http.post<MailboxCreated>('/api/admin/mailboxes', { email, displayName, password });
|
||||||
|
}
|
||||||
|
newMailboxPassword(id: number): Observable<MailboxCreated> {
|
||||||
|
return this.http.post<MailboxCreated>(`/api/admin/mailboxes/${id}/password`, {});
|
||||||
|
}
|
||||||
|
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||||
|
}
|
||||||
|
|
||||||
// folders
|
// folders
|
||||||
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
|
||||||
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
createFolder(name: string): Observable<Folder> { return this.http.post<Folder>('/api/folders', { name }); }
|
||||||
|
|||||||
@@ -53,6 +53,25 @@ export interface Profile {
|
|||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
quotaBytes: number;
|
quotaBytes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
admin?: boolean; // may manage mailboxes (Settings → Mailboxes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MailboxAdminController.MailboxView */
|
||||||
|
export interface Mailbox {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
active: boolean;
|
||||||
|
admin: boolean;
|
||||||
|
usedBytes: number;
|
||||||
|
quotaBytes: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** password — generated by the server, shown once; null when the administrator chose it */
|
||||||
|
export interface MailboxCreated {
|
||||||
|
mailbox: Mailbox;
|
||||||
|
password: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { MailStateService } from '../../core/mail-state.service';
|
||||||
|
import { Mailbox, errorText, formatBytes } from '../../core/models';
|
||||||
|
import { ToastService } from '../../ui/toast.service';
|
||||||
|
|
||||||
|
/** Settings → Mailboxes (administrators): create no-reply@, monitor@, support@ …, new password, switch off. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-mailboxes-section',
|
||||||
|
imports: [FormsModule, MatIconModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `
|
||||||
|
<section class="glass-panel static p-5 md:p-6 xl:col-span-2" aria-labelledby="mailboxes-title">
|
||||||
|
<h2 id="mailboxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||||
|
<mat-icon class="text-pink-500">group</mat-icon> MAILBOXES
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form (ngSubmit)="create()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr_auto] gap-3 items-end mb-4">
|
||||||
|
<div><label class="synth-label" for="mb-email">Address</label>
|
||||||
|
<input id="mb-email" name="email" class="synth-input" [(ngModel)]="email" [placeholder]="'no-reply@' + domain()" autocomplete="off" required></div>
|
||||||
|
<div><label class="synth-label" for="mb-name">Name</label>
|
||||||
|
<input id="mb-name" name="name" class="synth-input" [(ngModel)]="name" maxlength="100" autocomplete="off"></div>
|
||||||
|
<div><label class="synth-label" for="mb-password">Password (empty — generate)</label>
|
||||||
|
<input id="mb-password" name="password" type="password" class="synth-input" [(ngModel)]="password" autocomplete="new-password"></div>
|
||||||
|
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()"><mat-icon>add</mat-icon> CREATE</button>
|
||||||
|
</form>
|
||||||
|
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
|
||||||
|
|
||||||
|
@if (secret(); as s) {
|
||||||
|
<div class="p-3 mb-4 rounded-lg border border-pink-500/40 bg-slate-900/60" role="status">
|
||||||
|
<div class="text-xs text-slate-400 mb-1">Password of <span class="font-mono text-cyan-300">{{ s.email }}</span> — shown once, copy it now</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-pink-300 break-all select-all">{{ s.password }}</code>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ul class="divide-y divide-cyan-500/10">
|
||||||
|
@for (m of mailboxes(); track m.id) {
|
||||||
|
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
|
||||||
|
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
|
||||||
|
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
|
||||||
|
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
|
||||||
|
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
||||||
|
@if (m.email !== me()) {
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
|
||||||
|
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
|
||||||
|
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class MailboxesSection {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private state = inject(MailStateService);
|
||||||
|
private toasts = inject(ToastService);
|
||||||
|
|
||||||
|
readonly mailboxes = signal<Mailbox[]>([]);
|
||||||
|
readonly secret = signal<{ email: string; password: string } | null>(null);
|
||||||
|
readonly error = signal('');
|
||||||
|
readonly busy = signal(false);
|
||||||
|
readonly me = computed(() => this.state.profile()?.email ?? '');
|
||||||
|
readonly domain = computed(() => this.me().split('@')[1] ?? 'example.com');
|
||||||
|
|
||||||
|
email = '';
|
||||||
|
name = '';
|
||||||
|
password = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(bytes: number): string {
|
||||||
|
return formatBytes(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: e => this.error.set(errorText(e)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
const email = this.email.trim();
|
||||||
|
if (!email) { this.error.set('Enter an address'); return; }
|
||||||
|
this.error.set('');
|
||||||
|
this.busy.set(true);
|
||||||
|
this.api.createMailbox(email, this.name.trim(), this.password).subscribe({
|
||||||
|
next: created => {
|
||||||
|
this.busy.set(false);
|
||||||
|
this.email = this.name = this.password = '';
|
||||||
|
if (created.password) { this.secret.set({ email: created.mailbox.email, password: created.password }); }
|
||||||
|
this.toasts.ok('Mailbox ' + created.mailbox.email + ' created');
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: e => { this.busy.set(false); this.error.set(errorText(e)); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newPassword(m: Mailbox): void {
|
||||||
|
if (!confirm('New password for ' + m.email + '? The old one stops working at once.')) return;
|
||||||
|
this.api.newMailboxPassword(m.id).subscribe({
|
||||||
|
next: created => created.password && this.secret.set({ email: m.email, password: created.password }),
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(m: Mailbox): void {
|
||||||
|
this.api.setMailboxActive(m.id, !m.active).subscribe({
|
||||||
|
next: () => this.load(),
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(text: string): void {
|
||||||
|
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
|
|||||||
import { errorText, formatBytes } from '../../core/models';
|
import { errorText, formatBytes } from '../../core/models';
|
||||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||||
import { ToastService } from '../../ui/toast.service';
|
import { ToastService } from '../../ui/toast.service';
|
||||||
|
import { MailboxesSection } from './mailboxes.section';
|
||||||
|
|
||||||
/** Profile name, password, storage, look. */
|
/** Profile name, password, storage, look. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
imports: [DatePipe, FormsModule, MatIconModule],
|
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||||
@@ -97,6 +98,10 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
@if (state.profile()?.admin) {
|
||||||
|
<app-mailboxes-section />
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user