From cf84797e68908afde742017800e71847b645449e Mon Sep 17 00:00:00 2001 From: Liosha84 Date: Sat, 7 Nov 2026 14:15:24 +0100 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=81=D1=8B=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=BF=D0=BE=20+?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B5,=20=D0=BB=D0=BE=D0=B2=D1=83?= =?UTF-8?q?=D1=88=D0=BA=D0=B0=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=87=D1=83=D0=B6=D0=B8=D1=85=20=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=D0=BE=D0=B2,=20=D1=81=D0=BB=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=D0=B1=D0=BD=D0=BE=D0=B5=20API=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20Jambotron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V6 mail_lists + mail_list_members: письмо на alerts-prod@ → каждому участнику своя копия в папку рассылки; закрытая рассылка — только от вошедших отправителей (550 снаружи) - bob+prod@ → ящик bob@, папка prod (создаётся сама; системные папки нельзя) - TRAP: чужие адреса → одна копия в INBOX отправителя; наши ящики и рассылки доставляются - /api/service/** (SERVICE_TOKEN из secrets-init): аккаунты (пароль один раз, выключение → вон из рассылок), рассылки - MailDirectory: одно пространство адресов для ящиков и рассылок, без «+» в именах - Настройки → LISTS для администраторов - 86 тестов; живой прогон на Postgres 17: миграции V1–V6, API, SMTP (рассылка/метка/ловушка) Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +- .../com/mailserver/config/MailDomains.java | 18 ++ .../com/mailserver/config/SecurityConfig.java | 2 + .../controller/MailboxAdminController.java | 64 ++++--- .../controller/ServiceController.java | 162 ++++++++++++++++++ .../com/mailserver/service/LocalDelivery.java | 8 +- .../com/mailserver/service/MailDirectory.java | 107 ++++++++++++ .../com/mailserver/service/MailLists.java | 101 +++++++++++ .../com/mailserver/service/MailService.java | 43 ++++- .../mailserver/service/SubmissionService.java | 4 +- .../com/mailserver/service/UserService.java | 5 + .../java/com/mailserver/smtp/MailRouter.java | 98 ++++++++--- .../java/com/mailserver/smtp/SmtpBackend.java | 6 +- .../java/com/mailserver/smtp/SmtpServer.java | 3 +- .../src/main/resources/application.properties | 3 + .../main/resources/db/migration/V6__lists.sql | 18 ++ .../mailserver/config/MailDomainsTest.java | 19 ++ .../MailboxAdminControllerTest.java | 32 +++- .../controller/ServiceControllerTest.java | 106 ++++++++++++ .../service/DeliveryFolderTest.java | 21 +++ .../com/mailserver/smtp/MailRouterTest.java | 72 ++++++-- docker-compose.image.yml | 2 +- docker-compose.jambotron.yml | 2 +- docker-compose.public.yml | 2 +- frontend/src/app/core/api.service.ts | 9 +- frontend/src/app/core/models.ts | 10 ++ .../src/app/pages/settings/lists.section.ts | 154 +++++++++++++++++ .../src/app/pages/settings/settings.page.ts | 4 +- 28 files changed, 1013 insertions(+), 71 deletions(-) create mode 100644 backend/src/main/java/com/mailserver/controller/ServiceController.java create mode 100644 backend/src/main/java/com/mailserver/service/MailDirectory.java create mode 100644 backend/src/main/java/com/mailserver/service/MailLists.java create mode 100644 backend/src/main/resources/db/migration/V6__lists.sql create mode 100644 backend/src/test/java/com/mailserver/config/MailDomainsTest.java create mode 100644 backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java create mode 100644 backend/src/test/java/com/mailserver/service/DeliveryFolderTest.java create mode 100644 frontend/src/app/pages/settings/lists.section.ts diff --git a/README.md b/README.md index 1430e5c..c04d52b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,14 @@ **Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**. -**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб), никому не доставляется: одна копия ложится в его же INBOX, наружу не уходит ничего. Для `staging@` — тестовые письма staging-стенда (забыл пароль, уведомления) не попадают к живым людям; смотреть их — открыть `staging@` (OPEN). Входящая почта на такой ящик приходит как обычно. +**Ловушка у ящика (TRAP):** Настройки → MAILBOXES → кнопка `outbox` у ящика. Всё, что такой ящик отправляет (SMTP или веб) **на чужие адреса**, никому не доставляется: одна копия ложится в его же INBOX. Письма на наши адреса и рассылки (`alerts-staging@`) доставляются как обычно. Для `staging@` — тестовые письма staging-стенда не попадают к живым людям, а алерты staging доходят до команды. + +**Папки по `+метке`:** письмо на `bob+prod@jambocheck.com` ложится в ящик `bob@` в папку **prod** (создаётся сама). В имени ящика и рассылки `+` нельзя. Метка не может быть системной папкой (Spam, Trash …) — тогда INBOX. + +**Рассылки (LISTS):** Настройки → LISTS. Письмо на адрес рассылки (`alerts-prod@`) → **каждому участнику своя копия** (своё «прочитано») в папку рассылки. Закрытая рассылка (по умолчанию) принимает письма только от вошедших отправителей (Kuma через `monitor@`, Jambotron) — снаружи 550. Адрес рассылки не может совпадать с ящиком. + +**Служебное API для Jambotron** (`/api/service/**`, заголовок `Authorization: Bearer `; токен генерирует `secrets-init` в томе секретов — файл `SERVICE_TOKEN`, без токена API выключено): +`PUT /accounts/{email}` `{displayName, active}` — создать (201 + пароль один раз) или изменить; `active:false` — выключить и убрать из всех рассылок · `POST /accounts/{email}/password` — новый пароль один раз · `GET /accounts/{email}` · `GET /lists` · `PUT /lists/{address}` `{folder, description, openToOutside, members[]}` — точный состав · `DELETE /lists/{address}`. **Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь). diff --git a/backend/src/main/java/com/mailserver/config/MailDomains.java b/backend/src/main/java/com/mailserver/config/MailDomains.java index 94cb4c4..c5672a9 100644 --- a/backend/src/main/java/com/mailserver/config/MailDomains.java +++ b/backend/src/main/java/com/mailserver/config/MailDomains.java @@ -44,6 +44,24 @@ public class MailDomains { return domains; } + /** + * Address without its "+tag": {@code bob+prod@x} → {@code bob@x}. Mailbox and list names never contain "+", + * so the part after it is a label chosen by the sender — the folder the message goes to. + */ + public static String baseOf(String address) { + String lower = address.toLowerCase(Locale.ROOT); + int at = lower.lastIndexOf('@'); + int plus = lower.indexOf('+'); + return plus > 0 && plus < at ? lower.substring(0, plus) + lower.substring(at) : lower; + } + + /** The "+tag" of an address, or null: {@code bob+prod@x} → {@code prod}. */ + public static String tagOf(String address) { + int at = address.lastIndexOf('@'); + int plus = address.indexOf('+'); + return plus > 0 && plus < at - 1 ? address.substring(plus + 1, at) : null; + } + public static String domainOf(String address) { int at = address == null ? -1 : address.lastIndexOf('@'); return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT); diff --git a/backend/src/main/java/com/mailserver/config/SecurityConfig.java b/backend/src/main/java/com/mailserver/config/SecurityConfig.java index ee1c390..02d55da 100644 --- a/backend/src/main/java/com/mailserver/config/SecurityConfig.java +++ b/backend/src/main/java/com/mailserver/config/SecurityConfig.java @@ -52,6 +52,8 @@ public class SecurityConfig { .cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeExchange(exchange -> exchange .pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() + // service API for Jambotron: its own token, checked by ServiceController (not a mailbox login) + .pathMatchers("/api/service/**").permitAll() .pathMatchers("/api/**", "/actuator/**").authenticated() // the web client (static files, SPA routes) .anyExchange().permitAll() diff --git a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java index e5eccc6..85c11d8 100644 --- a/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java +++ b/backend/src/main/java/com/mailserver/controller/MailboxAdminController.java @@ -4,6 +4,8 @@ import com.mailserver.config.MailAdmins; import com.mailserver.config.MailDomains; import com.mailserver.domain.User; import com.mailserver.dto.LoginResponse; +import com.mailserver.service.MailDirectory; +import com.mailserver.service.MailLists; import com.mailserver.service.MailService; import com.mailserver.service.UserService; import com.mailserver.util.JwtUtils; @@ -13,6 +15,7 @@ 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.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -25,12 +28,9 @@ 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; +import java.util.List; /** * Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@, @@ -42,8 +42,7 @@ import java.util.regex.Pattern; 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; + private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD; public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap, long usedBytes, long quotaBytes, Instant createdAt) { @@ -72,9 +71,14 @@ public class MailboxAdminController { private final MailDomains domains; private final MailAdmins admins; private final JwtUtils jwt; + private final MailDirectory directory; + private final MailLists lists; - public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt) { + public MailboxAdminController(UserService users, MailService mail, MailDomains domains, MailAdmins admins, JwtUtils jwt, + MailDirectory directory, MailLists lists) { this.jwt = jwt; + this.directory = directory; + this.lists = lists; this.users = users; this.mail = mail; this.domains = domains; @@ -92,22 +96,13 @@ public class MailboxAdminController { @ResponseStatus(HttpStatus.CREATED) public Mono create(Mono auth, @RequestBody Mono 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)) + return directory.createMailbox(request.email(), request.displayName(), password) + .doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail())) .flatMap(this::view) .map(view -> new Created(view, chosen.isEmpty() ? password : null)); }); @@ -166,6 +161,35 @@ public class MailboxAdminController { }))).flatMap(this::view); } + // --- Mailing lists (Settings → LISTS) --- + + public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside, + List members) { + } + + @GetMapping("/lists") + public Flux lists(Mono auth) { + return admin(auth).thenMany(lists.all()); + } + + /** Creates or replaces the list {@code address} (settings + the exact member set). */ + @PutMapping("/lists/{address}") + public Mono saveList(Mono auth, @PathVariable String address, + @RequestBody Mono body) { + return admin(auth).flatMap(me -> body.flatMap(request -> + directory.saveList(address, request.folder(), request.description(), request.openToOutside(), request.members()) + .doOnNext(list -> log.info("List {} saved by administrator {}: {} members", list.address(), me, list.members().size())))); + } + + @DeleteMapping("/lists/{address}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public Mono deleteList(Mono auth, @PathVariable String address) { + return admin(auth).flatMap(me -> lists.delete(address) + .flatMap(deleted -> deleted ? Mono.empty() + : Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found"))) + .doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me))); + } + private Mono admin(Mono auth) { return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) .map(Authentication::getName) @@ -189,8 +213,6 @@ public class MailboxAdminController { } static String randomPassword() { - byte[] bytes = new byte[18]; - new SecureRandom().nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return MailDirectory.randomPassword(); } } diff --git a/backend/src/main/java/com/mailserver/controller/ServiceController.java b/backend/src/main/java/com/mailserver/controller/ServiceController.java new file mode 100644 index 0000000..50d7a26 --- /dev/null +++ b/backend/src/main/java/com/mailserver/controller/ServiceController.java @@ -0,0 +1,162 @@ +package com.mailserver.controller; + +import com.mailserver.domain.User; +import com.mailserver.service.MailDirectory; +import com.mailserver.service.MailLists; +import com.mailserver.service.UserService; +import jakarta.validation.constraints.Size; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +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.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +/** + * Service API for Jambotron (staff mailboxes): token {@code SERVICE_TOKEN} in {@code Authorization: Bearer …}. + * Without the token configured the API is off (404). Accounts: create (password returned ONCE), switch on/off, + * new password; lists: the exact settings and members, Jambotron is the source of truth for the lists it keeps. + */ +@RestController +@RequestMapping("/api/service") +public class ServiceController { + + private static final Logger log = LoggerFactory.getLogger(ServiceController.class); + + /** @param displayName null — unchanged (new account: the name before @) */ + public record AccountRequest(@Size(max = 100) String displayName, Boolean active) { + } + + public record AccountView(String email, String displayName, boolean active, List lists) { + } + + /** @param password generated, only when the account was just created or got a new password */ + public record AccountResult(AccountView account, String password) { + } + + public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside, + List members) { + } + + private final byte[] token; + private final UserService users; + private final MailLists lists; + private final MailDirectory directory; + + public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists, + MailDirectory directory) { + this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8); + this.users = users; + this.lists = lists; + this.directory = directory; + } + + @GetMapping("/accounts/{email}") + public Mono account(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String email) { + check(auth); + return users.findOptional(email) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox"))) + .flatMap(this::view); + } + + /** Creates the account (201 + password once) or updates name / on-off (200). Switched off → out of every list. */ + @PutMapping("/accounts/{email}") + public Mono> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String email, @RequestBody Mono body) { + check(auth); + String address = directory.address(email); + return body.defaultIfEmpty(new AccountRequest(null, null)).flatMap(request -> users.findOptional(address) + .flatMap(user -> update(user, request).flatMap(this::view) + .map(view -> ResponseEntity.ok(new AccountResult(view, null)))) + .switchIfEmpty(Mono.defer(() -> { + String password = MailDirectory.randomPassword(); + return directory.createMailbox(address, request.displayName(), password) + .flatMap(user -> Boolean.FALSE.equals(request.active()) ? update(user, request) : Mono.just(user)) + .doOnNext(user -> log.info("Mailbox {} created by Jambotron", user.getEmail())) + .flatMap(this::view) + .map(view -> ResponseEntity.status(HttpStatus.CREATED).body(new AccountResult(view, password))); + }))); + } + + /** New generated password, returned once (Jambotron profile → «new mail password»). */ + @PostMapping("/accounts/{email}/password") + public Mono password(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String email) { + check(auth); + String password = MailDirectory.randomPassword(); + return users.findOptional(email) + .switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such mailbox"))) + .flatMap(user -> users.setPassword(user, password)) + .doOnNext(user -> log.info("Password of {} set by Jambotron", user.getEmail())) + .flatMap(this::view) + .map(view -> new AccountResult(view, password)); + } + + @GetMapping("/lists") + public Flux lists(@RequestHeader(value = "Authorization", required = false) String auth) { + check(auth); + return lists.all(); + } + + @PutMapping("/lists/{address}") + public Mono saveList(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String address, @RequestBody Mono body) { + check(auth); + return body.flatMap(request -> directory.saveList(address, request.folder(), request.description(), + request.openToOutside(), request.members())) + .doOnNext(list -> log.info("List {} saved by Jambotron: {}", list.address(), list.members())); + } + + @DeleteMapping("/lists/{address}") + public Mono> deleteList(@RequestHeader(value = "Authorization", required = false) String auth, + @PathVariable String address) { + check(auth); + return lists.delete(address).map(deleted -> deleted + ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build()); + } + + private Mono update(User user, AccountRequest request) { + Mono named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user) + : users.updateDisplayName(user, request.displayName()); + if (request.active() == null || request.active() == user.isActive()) { + return named; + } + boolean on = request.active(); + return named.flatMap(u -> users.setActive(u, on)) + .flatMap(u -> on ? Mono.just(u) : lists.removeEverywhere(u.getId()).thenReturn(u)) + .doOnNext(u -> log.info("Mailbox {} {} by Jambotron", u.getEmail(), on ? "switched on" : "switched off")); + } + + private Mono view(User user) { + return lists.listsOf(user.getId()).collectList() + .map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in)); + } + + /** 404 when the API is off, 401 for a wrong token (constant-time compare). */ + private void check(String auth) { + if (token == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND); + } + byte[] given = auth != null && auth.startsWith("Bearer ") + ? auth.substring(7).trim().getBytes(StandardCharsets.UTF_8) : new byte[0]; + if (!MessageDigest.isEqual(token, given)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + } +} diff --git a/backend/src/main/java/com/mailserver/service/LocalDelivery.java b/backend/src/main/java/com/mailserver/service/LocalDelivery.java index 049ada5..250acce 100644 --- a/backend/src/main/java/com/mailserver/service/LocalDelivery.java +++ b/backend/src/main/java/com/mailserver/service/LocalDelivery.java @@ -54,6 +54,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { /** Blocking: call from a worker thread. */ public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted) throws MessagingException, IOException { + deliver(raw, mailbox, null, remoteIp, envelopeFrom, trusted); + } + + /** @param folder own folder of the mailbox ("+tag", mailing list), created when missing; null — INBOX */ + public void deliver(byte[] raw, String mailbox, String folder, String remoteIp, String envelopeFrom, boolean trusted) + throws MessagingException, IOException { MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw)); String senderEmail = envelopeFrom; @@ -80,7 +86,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender { com.mailserver.domain.Message stored = mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam, addresses(mime, jakarta.mail.Message.RecipientType.TO), - addresses(mime, jakarta.mail.Message.RecipientType.CC)).block(); + addresses(mime, jakarta.mail.Message.RecipientType.CC), folder).block(); if (stored != null) { for (FilePart file : attachments(mime)) { mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); diff --git a/backend/src/main/java/com/mailserver/service/MailDirectory.java b/backend/src/main/java/com/mailserver/service/MailDirectory.java new file mode 100644 index 0000000..234a765 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/MailDirectory.java @@ -0,0 +1,107 @@ +package com.mailserver.service; + +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Mailboxes and mailing lists share one address space: one rule for both, used by Settings (administrators) + * and by the service API (Jambotron). Names never contain "+" — the part after it is a folder label. + */ +@Service +public class MailDirectory { + + private static final Pattern LOCAL_PART = Pattern.compile("[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?"); + private static final Pattern FOLDER = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}"); + public static final int MIN_PASSWORD = 8; + + private final UserService users; + private final MailLists lists; + private final MailDomains domains; + + public MailDirectory(UserService users, MailLists lists, MailDomains domains) { + this.users = users; + this.lists = lists; + this.domains = domains; + } + + /** Lower-case address of our domain with an allowed name, or 400. */ + public String address(String raw) { + String email = raw == null ? "" : raw.trim().toLowerCase(Locale.ROOT); + int at = email.lastIndexOf('@'); + if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) { + throw bad("Not a mailbox address: " + email + " (letters, digits, . _ - ; no +)"); + } + if (!domains.isLocal(email)) { + throw bad("Only in " + String.join(", ", domains.domains().stream().sorted().toList())); + } + return email; + } + + /** New mailbox; the address must not be taken by a mailbox or a list. 409 when taken. */ + public Mono createMailbox(String rawEmail, String displayName, String password) { + String email = address(rawEmail); + if (password == null || password.length() < MIN_PASSWORD) { + return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters")); + } + return lists.exists(email).flatMap(isList -> isList + ? Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailing list")) + : users.register(email, password, displayName) + .onErrorMap(IllegalArgumentException.class, + e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))); + } + + /** + * Creates or replaces a list. Members: mailboxes of this server (addresses); unknown ones → 400 naming them. + */ + public Mono saveList(String rawAddress, String rawFolder, String description, + boolean openToOutside, List members) { + String address = address(rawAddress); + String folder = rawFolder == null || rawFolder.isBlank() ? null : rawFolder.trim(); + if (folder != null && (!FOLDER.matcher(folder).matches() + || MailService.SYSTEM_FOLDERS.stream().anyMatch(folder::equalsIgnoreCase))) { + return Mono.error(bad("Folder: letters, digits, . _ - (up to 50), not a system folder; empty — INBOX")); + } + String about = description == null || description.isBlank() ? null : description.trim(); + if (about != null && about.length() > 255) { + return Mono.error(bad("Description: up to 255 characters")); + } + List wanted = members == null ? List.of() + : members.stream().map(m -> m.trim().toLowerCase(Locale.ROOT)).filter(m -> !m.isEmpty()).distinct().toList(); + return users.findOptional(address).hasElement().flatMap(isMailbox -> { + if (isMailbox) { + return Mono.error(new ResponseStatusException(HttpStatus.CONFLICT, "This address is a mailbox")); + } + List unknown = new ArrayList<>(); + return Flux.fromIterable(wanted) + .concatMap(member -> users.findOptional(member) + .map(User::getId) + .switchIfEmpty(Mono.fromRunnable(() -> unknown.add(member)))) + .collectList() + .flatMap(ids -> unknown.isEmpty() + ? lists.save(address, folder, about, openToOutside, ids) + : Mono.error(bad("No such mailbox: " + String.join(", ", unknown)))); + }); + } + + public static String randomPassword() { + byte[] bytes = new byte[18]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static ResponseStatusException bad(String message) { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } +} diff --git a/backend/src/main/java/com/mailserver/service/MailLists.java b/backend/src/main/java/com/mailserver/service/MailLists.java new file mode 100644 index 0000000..c6dc6a6 --- /dev/null +++ b/backend/src/main/java/com/mailserver/service/MailLists.java @@ -0,0 +1,101 @@ +package com.mailserver.service; + +import org.springframework.r2dbc.core.DatabaseClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +/** + * Mailing lists (V6): {@code alerts-prod@} → every member gets their own copy in the list's folder. + * Kept by the administrators (Settings → LISTS) and by Jambotron through the service API. + */ +@Service +public class MailLists { + + /** @param folder where members get the copy; null — INBOX */ + public record MailList(Long id, String address, String folder, String description, boolean openToOutside, + List members) { + } + + private final DatabaseClient db; + + public MailLists(DatabaseClient db) { + this.db = db; + } + + public Mono find(String address) { + return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists WHERE address = :a") + .bind("a", address.toLowerCase(Locale.ROOT)) + .map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class), + row.get("folder", String.class), row.get("description", String.class), + Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of())) + .one() + .flatMap(this::withMembers); + } + + public Mono exists(String address) { + return db.sql("SELECT 1 FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT)) + .map((row, meta) -> true).one().defaultIfEmpty(false); + } + + public Flux all() { + return db.sql("SELECT id, address, folder, description, open_to_outside FROM mail_lists ORDER BY address") + .map((row, meta) -> new MailList(row.get("id", Long.class), row.get("address", String.class), + row.get("folder", String.class), row.get("description", String.class), + Boolean.TRUE.equals(row.get("open_to_outside", Boolean.class)), List.of())) + .all() + .concatMap(this::withMembers); + } + + /** Lists this mailbox is a member of (shown to the mailbox owner). */ + public Flux listsOf(Long userId) { + return db.sql("SELECT l.address FROM mail_lists l JOIN mail_list_members m ON m.list_id = l.id " + + "WHERE m.user_id = :u ORDER BY l.address") + .bind("u", userId).map((row, meta) -> row.get("address", String.class)).all(); + } + + /** + * Creates or replaces the list: settings and the exact member set. Members are mailbox ids (checked by the caller). + */ + @Transactional + public Mono save(String address, String folder, String description, boolean openToOutside, + Collection memberIds) { + String a = address.toLowerCase(Locale.ROOT); + var insert = db.sql("INSERT INTO mail_lists (address, folder, description, open_to_outside) " + + "VALUES (:a, :f, :d, :o) ON CONFLICT (address) DO UPDATE " + + "SET folder = EXCLUDED.folder, description = EXCLUDED.description, " + + "open_to_outside = EXCLUDED.open_to_outside RETURNING id") + .bind("a", a).bind("o", openToOutside); + insert = folder == null ? insert.bindNull("f", String.class) : insert.bind("f", folder); + insert = description == null ? insert.bindNull("d", String.class) : insert.bind("d", description); + return insert.map((row, meta) -> row.get("id", Long.class)).one() + .flatMap(id -> db.sql("DELETE FROM mail_list_members WHERE list_id = :l").bind("l", id).then() + .thenMany(Flux.fromIterable(memberIds).distinct() + .concatMap(member -> db.sql("INSERT INTO mail_list_members (list_id, user_id) VALUES (:l, :u)") + .bind("l", id).bind("u", member).then())) + .then(find(a))); + } + + public Mono delete(String address) { + return db.sql("DELETE FROM mail_lists WHERE address = :a").bind("a", address.toLowerCase(Locale.ROOT)) + .fetch().rowsUpdated().map(n -> n > 0); + } + + /** Takes the mailbox out of every list (mailbox switched off / role removed in Jambotron). */ + public Mono removeEverywhere(Long userId) { + return db.sql("DELETE FROM mail_list_members WHERE user_id = :u").bind("u", userId).fetch().rowsUpdated(); + } + + private Mono withMembers(MailList list) { + return db.sql("SELECT u.email FROM mail_list_members m JOIN users u ON u.id = m.user_id " + + "WHERE m.list_id = :l ORDER BY u.email") + .bind("l", list.id()).map((row, meta) -> row.get("email", String.class)).all().collectList() + .map(members -> new MailList(list.id(), list.address(), list.folder(), list.description(), + list.openToOutside(), members)); + } +} diff --git a/backend/src/main/java/com/mailserver/service/MailService.java b/backend/src/main/java/com/mailserver/service/MailService.java index 1837365..851ba89 100644 --- a/backend/src/main/java/com/mailserver/service/MailService.java +++ b/backend/src/main/java/com/mailserver/service/MailService.java @@ -162,6 +162,34 @@ public class MailService { .switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted"))); } + private static final java.util.regex.Pattern DELIVERY_FOLDER = java.util.regex.Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,49}"); + + /** + * A "+tag" / list folder that may be used as a folder name, or null (→ INBOX). A sender never puts mail into + * a system folder (Spam, Trash, Sent …) this way. + */ + static String deliveryFolderName(String raw) { + if (raw == null || !DELIVERY_FOLDER.matcher(raw).matches() || SYSTEM_FOLDERS.stream().anyMatch(raw::equalsIgnoreCase)) { + return null; + } + return raw; + } + + /** The folder with this name (any case), created as an own folder when missing; two at once — one wins. */ + private Mono deliveryFolder(Long userId, String name) { + Mono existing = folderRepository.findByUserId(userId) + .filter(folder -> folder.getName().equalsIgnoreCase(name)) + .next(); + return existing.switchIfEmpty(Mono.defer(() -> { + Folder folder = new Folder(); + folder.setUserId(userId); + folder.setName(name); + folder.setCreatedAt(Instant.now()); + return folderRepository.save(folder) + .onErrorResume(org.springframework.dao.DataIntegrityViolationException.class, e -> existing); + })); + } + private Mono systemFolder(Long userId, String name) { return folderRepository.findByUserIdAndName(userId, name) .switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found"))); @@ -253,12 +281,25 @@ public class MailService { public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, String subject, String body, boolean isHtml, boolean externalSpamFlag, String toHeader, String ccHeader) { + return receiveMessage(senderEmail, senderName, recipientEmail, subject, body, isHtml, externalSpamFlag, + toHeader, ccHeader, null); + } + + /** + * @param folder own folder for the message ("+tag" of the address, folder of a mailing list), created when + * missing; null or not a valid folder name — INBOX. Spam still goes to Spam. + */ + public Mono receiveMessage(String senderEmail, String senderName, String recipientEmail, + String subject, String body, boolean isHtml, boolean externalSpamFlag, + String toHeader, String ccHeader, String targetFolder) { return userRepository.findByEmail(recipientEmail) .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) .flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail) .flatMap(blacklisted -> { boolean isSpam = blacklisted || externalSpamFlag; - return systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX") + String own = isSpam ? null : deliveryFolderName(targetFolder); + return (own == null ? systemFolder(recipient.getId(), isSpam ? "Spam" : "INBOX") + : deliveryFolder(recipient.getId(), own)) .flatMap(folder -> { Message message = new Message(); message.setFolderId(folder.getId()); diff --git a/backend/src/main/java/com/mailserver/service/SubmissionService.java b/backend/src/main/java/com/mailserver/service/SubmissionService.java index e37d133..a022e17 100644 --- a/backend/src/main/java/com/mailserver/service/SubmissionService.java +++ b/backend/src/main/java/com/mailserver/service/SubmissionService.java @@ -88,10 +88,10 @@ public class SubmissionService { for (String address : concat(to, cc)) { SmtpBackend.Route route = router.route(address, user.getEmail()); switch (route) { - case LOCAL, OUTBOUND -> recipients.add(new SmtpBackend.Recipient(address, route)); + case LOCAL, OUTBOUND, TRAP -> recipients.add(new SmtpBackend.Recipient(address, route)); case UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address); case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + address); - case RELAY_DENIED -> throw new IllegalStateException("logged-in user denied relay"); + case RELAY_DENIED, LIST_CLOSED -> throw new IllegalStateException("logged-in user denied: " + route); } } String messageId = UUID.randomUUID() + "@" + hostname; diff --git a/backend/src/main/java/com/mailserver/service/UserService.java b/backend/src/main/java/com/mailserver/service/UserService.java index aae419d..1cc0ada 100644 --- a/backend/src/main/java/com/mailserver/service/UserService.java +++ b/backend/src/main/java/com/mailserver/service/UserService.java @@ -74,6 +74,11 @@ public class UserService { return userRepository.save(user).then(); } + /** The mailbox, or empty (no error). */ + public Mono findOptional(String email) { + return userRepository.findByEmail(email.toLowerCase(java.util.Locale.ROOT)); + } + public Flux all() { return userRepository.findAll(); } diff --git a/backend/src/main/java/com/mailserver/smtp/MailRouter.java b/backend/src/main/java/com/mailserver/smtp/MailRouter.java index 4170a48..4af8916 100644 --- a/backend/src/main/java/com/mailserver/smtp/MailRouter.java +++ b/backend/src/main/java/com/mailserver/smtp/MailRouter.java @@ -7,18 +7,25 @@ import com.mailserver.domain.User; import com.mailserver.repository.UserRepository; import com.mailserver.service.DkimService; import com.mailserver.service.LocalDelivery; +import com.mailserver.service.MailLists; import com.mailserver.service.UserService; import org.springframework.stereotype.Service; +import java.util.LinkedHashSet; import java.util.Locale; +import java.util.Set; /** * Where mail goes — one rule for SMTP and for the web client: - * 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. + *
    + *
  • a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);
  • + *
  • a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder + * (or the "+tag" folder); a closed list takes mail only from logged-in senders;
  • + *
  • a TRAP sender (staging@) and an address that is not ours → nothing leaves: one copy into the sender's INBOX + * (our own mailboxes and lists still get their mail);
  • + *
  • catch-all set → everything else into the catch-all mailbox; otherwise our domain → "no such user", + * someone else's domain → the delivery queue, only for a logged-in sender (never an open relay).
  • + *
*/ @Service public class MailRouter implements SmtpBackend { @@ -30,9 +37,10 @@ public class MailRouter implements SmtpBackend { private final OutboundQueue queue; private final DkimService dkim; private final SmtpDeliveryClient client; + private final MailLists lists; public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery, - OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client) { + OutboundQueue queue, DkimService dkim, SmtpDeliveryClient client, MailLists lists) { this.domains = domains; this.users = users; this.userService = userService; @@ -40,6 +48,7 @@ public class MailRouter implements SmtpBackend { this.queue = queue; this.dkim = dkim; this.client = client; + this.lists = lists; } @Override @@ -50,11 +59,18 @@ public class MailRouter implements SmtpBackend { @Override public Route route(String recipient, String authenticatedUser) { - if (isTrap(authenticatedUser)) { - return Route.LOCAL; + if (domains.isLocal(recipient)) { + String base = MailDomains.baseOf(recipient); + if (mailboxExists(recipient) || mailboxExists(base)) { + return Route.LOCAL; + } + MailLists.MailList list = lists.find(base).block(); + if (list != null) { + return list.openToOutside() || authenticatedUser != null ? Route.LOCAL : Route.LIST_CLOSED; + } } - if (domains.isLocal(recipient) && mailboxExists(recipient)) { - return Route.LOCAL; + if (isTrap(authenticatedUser)) { + return Route.TRAP; } if (domains.catchAll() != null) { return Route.LOCAL; @@ -71,18 +87,13 @@ 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; - } + // one copy per mailbox + folder, even when an address and a list both lead there + Set targets = new LinkedHashSet<>(); byte[] signed = null; for (Recipient recipient : envelope.recipients()) { switch (recipient.route()) { - case LOCAL -> { - String mailbox = mailbox(recipient.address()); - localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted); - } + case LOCAL -> targets.addAll(targets(recipient.address())); + case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null)); case OUTBOUND -> { if (signed == null) { signed = dkim.sign(message); @@ -92,17 +103,50 @@ public class MailRouter implements SmtpBackend { default -> throw new IllegalStateException("Recipient was not accepted: " + recipient); } } - } - - /** Mailbox that stores mail to this address: its own if it exists here, otherwise the catch-all one. */ - String mailbox(String address) { - if (domains.catchAll() == null || (domains.isLocal(address) && mailboxExists(address))) { - return address; + for (Target target : targets) { + localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted); } - return domains.catchAll(); } - /** The logged-in sender is an active trap mailbox. */ + /** Mailbox + folder (null — INBOX) that store mail to a local address. */ + record Target(String mailbox, String folder) { + } + + Set targets(String address) { + Set result = new LinkedHashSet<>(); + String lower = address.toLowerCase(Locale.ROOT); + if (domains.isLocal(lower)) { + if (mailboxExists(lower)) { + result.add(new Target(lower, null)); + return result; + } + String base = MailDomains.baseOf(lower); + String tag = MailDomains.tagOf(address); + if (mailboxExists(base)) { + result.add(new Target(base, tag)); + return result; + } + MailLists.MailList list = lists.find(base).block(); + if (list != null) { + for (String member : list.members()) { + result.add(new Target(member, tag != null ? tag : list.folder())); + } + return result; + } + } + if (domains.catchAll() != null) { + result.add(new Target(domains.catchAll(), null)); + } + return result; + } + + /** Mailbox that stores mail to this address: its own (without "+tag") if it exists here, otherwise the catch-all one. */ + String mailbox(String address) { + Set found = targets(address); + return found.isEmpty() ? address : found.iterator().next().mailbox(); + } + + /** The logged-in sender is a trap mailbox. */ boolean isTrap(String authenticatedUser) { if (authenticatedUser == null) { return false; diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java index 669a5e9..551085f 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpBackend.java @@ -10,8 +10,12 @@ public interface SmtpBackend { /** Where a recipient goes, decided at RCPT TO. */ enum Route { - /** A mailbox of ours (or the catch-all mailbox) — store it. */ + /** A mailbox or mailing list of ours (or the catch-all mailbox) — store it. */ LOCAL, + /** The sender is a trap mailbox and the address is not ours: one copy into the sender's own INBOX. */ + TRAP, + /** A mailing list of ours that takes mail only from logged-in senders. */ + LIST_CLOSED, /** Someone else's address, sender is logged in — queue for delivery. */ OUTBOUND, /** Address of our domain that does not exist. */ diff --git a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java index fd07a18..26cbd67 100644 --- a/backend/src/main/java/com/mailserver/smtp/SmtpServer.java +++ b/backend/src/main/java/com/mailserver/smtp/SmtpServer.java @@ -438,7 +438,7 @@ public class SmtpServer { String user = authenticatedUser; return blocking(() -> backend.route(address, user)) .map(route -> switch (route) { - case LOCAL, OUTBOUND -> { + case LOCAL, OUTBOUND, TRAP -> { recipients.add(new SmtpBackend.Recipient(address, route)); yield "250 2.1.5 Recipient OK\r\n"; } @@ -448,6 +448,7 @@ public class SmtpServer { yield "550 5.7.1 Relaying denied: authentication required\r\n"; } case OUTBOUND_DISABLED -> "550 5.7.1 Outbound delivery is disabled on this server\r\n"; + case LIST_CLOSED -> "550 5.7.1 This list takes mail only from logged-in senders\r\n"; }); } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bec04b3..e948ee6 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -31,6 +31,9 @@ mailserver.admin.password=${MAIL_ADMIN_PASSWORD:} 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:} +# Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off. +# In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN). +mailserver.service-token=${SERVICE_TOKEN:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:} # Attachments diff --git a/backend/src/main/resources/db/migration/V6__lists.sql b/backend/src/main/resources/db/migration/V6__lists.sql new file mode 100644 index 0000000..10462cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__lists.sql @@ -0,0 +1,18 @@ +-- Mailing lists (alerts-prod@ …): mail to the list address → a copy into the mailbox of EVERY member, +-- into the list's folder (created on first mail). Each member has their own copy — their own read/unread. +-- open_to_outside = false: only logged-in senders (Kuma, Jambotron) may write to the list. +CREATE TABLE mail_lists ( + id BIGSERIAL PRIMARY KEY, + address VARCHAR(255) UNIQUE NOT NULL, + folder VARCHAR(50), + description VARCHAR(255), + open_to_outside BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE mail_list_members ( + list_id BIGINT NOT NULL REFERENCES mail_lists(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (list_id, user_id) +); +CREATE INDEX idx_mail_list_members_user ON mail_list_members(user_id); diff --git a/backend/src/test/java/com/mailserver/config/MailDomainsTest.java b/backend/src/test/java/com/mailserver/config/MailDomainsTest.java new file mode 100644 index 0000000..123ef61 --- /dev/null +++ b/backend/src/test/java/com/mailserver/config/MailDomainsTest.java @@ -0,0 +1,19 @@ +package com.mailserver.config; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** "+tag" addresses: the base address is the mailbox, the tag is the folder. */ +class MailDomainsTest { + + @Test + void baseAndTag() { + assertThat(MailDomains.baseOf("Bob+Prod@Mail.test")).isEqualTo("bob@mail.test"); + assertThat(MailDomains.tagOf("Bob+Prod@Mail.test")).isEqualTo("Prod"); + assertThat(MailDomains.baseOf("bob@mail.test")).isEqualTo("bob@mail.test"); + assertThat(MailDomains.tagOf("bob@mail.test")).isNull(); + assertThat(MailDomains.tagOf("bob+@mail.test")).isNull(); + assertThat(MailDomains.tagOf("+x@mail.test")).isNull(); + } +} diff --git a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java index 6596166..41eb60b 100644 --- a/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java +++ b/backend/src/test/java/com/mailserver/controller/MailboxAdminControllerTest.java @@ -34,13 +34,22 @@ class MailboxAdminControllerTest { 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 com.mailserver.service.MailLists lists = mock(com.mailserver.service.MailLists.class); + private final com.mailserver.service.MailDirectory directory = new com.mailserver.service.MailDirectory(users, lists, domains); + private final MailboxAdminController controller = new MailboxAdminController(users, mail, domains, admins, jwt, directory, lists); 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(lists.exists(anyString())).thenReturn(Mono.just(false)); + when(lists.exists("alerts@jambocheck.com")).thenReturn(Mono.just(true)); + when(users.findOptional(anyString())).thenReturn(Mono.empty()); + when(users.findOptional("alice@jambocheck.com")).thenReturn(Mono.just(alice)); + when(lists.save(anyString(), any(), any(), any(Boolean.class), any())).thenAnswer(i -> + Mono.just(new com.mailserver.service.MailLists.MailList(5L, i.getArgument(0), i.getArgument(1), i.getArgument(2), + i.getArgument(3), List.of("alice@jambocheck.com")))); when(users.all()).thenReturn(Flux.just(alice, admin)); when(users.byId(1L)).thenReturn(Mono.just(admin)); when(users.byId(2L)).thenReturn(Mono.just(alice)); @@ -59,6 +68,27 @@ class MailboxAdminControllerTest { }); } + @Test + void mailboxAndListShareOneAddressSpaceNoPlusInNames() { + assertThat(status(() -> create("admin@jambocheck.com", "alerts@jambocheck.com", ""))).isEqualTo(HttpStatus.CONFLICT); + assertThat(status(() -> create("admin@jambocheck.com", "bob+x@jambocheck.com", ""))).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(create("admin@jambocheck.com", "jambotron_prod@jambocheck.com", "").mailbox().email()).isEqualTo("jambotron_prod@jambocheck.com"); + + var request = new MailboxAdminController.ListRequest("prod", "alerts", false, List.of("Alice@jambocheck.com")); + assertThat(controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block().members()) + .containsExactly("alice@jambocheck.com"); + assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alice@jambocheck.com", Mono.just(request)).block())) + .isEqualTo(HttpStatus.CONFLICT); + var unknown = new MailboxAdminController.ListRequest("prod", null, false, List.of("ghost@jambocheck.com")); + assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(unknown)).block())) + .isEqualTo(HttpStatus.BAD_REQUEST); + var badFolder = new MailboxAdminController.ListRequest("Trash", null, false, List.of()); + assertThat(status(() -> controller.saveList(as("admin@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(badFolder)).block())) + .isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(status(() -> controller.saveList(as("alice@jambocheck.com"), "alerts-prod@jambocheck.com", Mono.just(request)).block())) + .isEqualTo(HttpStatus.FORBIDDEN); + } + @Test void administratorSwitchesTrapOthersCanNot() { assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue(); diff --git a/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java new file mode 100644 index 0000000..eb833cd --- /dev/null +++ b/backend/src/test/java/com/mailserver/controller/ServiceControllerTest.java @@ -0,0 +1,106 @@ +package com.mailserver.controller; + +import com.mailserver.config.MailDomains; +import com.mailserver.domain.User; +import com.mailserver.service.MailDirectory; +import com.mailserver.service.MailLists; +import com.mailserver.service.UserService; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +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.catchThrowable; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** Service API for Jambotron: token only; account created once with a password; switched off → out of the lists. */ +class ServiceControllerTest { + + private static final String AUTH = "Bearer s3cret-token"; + + private final UserService users = mock(UserService.class); + private final MailLists lists = mock(MailLists.class); + private final MailDomains domains = new MailDomains("jambocheck.com", ""); + private final MailDirectory directory = new MailDirectory(users, lists, domains); + private final User existing = user(7L, "jambotronpw@jambocheck.com"); + + ServiceControllerTest() { + when(users.findOptional(anyString())).thenReturn(Mono.empty()); + when(users.findOptional("jambotronpw@jambocheck.com")).thenReturn(Mono.just(existing)); + when(lists.exists(anyString())).thenReturn(Mono.just(false)); + when(lists.listsOf(anyLong())).thenReturn(Flux.just("alerts-prod@jambocheck.com")); + when(lists.removeEverywhere(anyLong())).thenReturn(Mono.just(1L)); + when(users.register(anyString(), anyString(), any())).thenAnswer(i -> Mono.just(user(8L, 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.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0))); + } + + private ServiceController api(String token) { + return new ServiceController(token, users, lists, directory); + } + + @Test + void offWithoutTokenWrongTokenRefused() { + assertThat(status(() -> api("").account(AUTH, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(status(() -> api("s3cret-token").account("Bearer nope", "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(status(() -> api("s3cret-token").account(null, "jambotronpw@jambocheck.com"))).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(api("s3cret-token").account(AUTH, "jambotronpw@jambocheck.com").block().lists()) + .containsExactly("alerts-prod@jambocheck.com"); + } + + @Test + void newAccountGetsPasswordOnceExistingDoesNot() { + var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com", + Mono.just(new ServiceController.AccountRequest("Moder", true))).block(); + assertThat(created.getStatusCode().value()).isEqualTo(201); + assertThat(created.getBody().password()).hasSizeGreaterThan(20); + + var again = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", + Mono.just(new ServiceController.AccountRequest(null, null))).block(); + assertThat(again.getStatusCode().value()).isEqualTo(200); + assertThat(again.getBody().password()).isNull(); + verify(lists, never()).removeEverywhere(anyLong()); + + assertThat(status(() -> api("s3cret-token").saveAccount(AUTH, "x@gmail.com", Mono.empty()))).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void switchedOffLeavesEveryList() { + var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com", + Mono.just(new ServiceController.AccountRequest(null, false))).block(); + assertThat(off.getBody().account().active()).isFalse(); + verify(lists).removeEverywhere(7L); + + var reset = api("s3cret-token").password(AUTH, "jambotronpw@jambocheck.com").block(); + assertThat(reset.password()).isNotBlank(); + verify(users).setPassword(existing, reset.password()); + } + + private static HttpStatus status(Runnable call) { + Throwable error = 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; + } +} diff --git a/backend/src/test/java/com/mailserver/service/DeliveryFolderTest.java b/backend/src/test/java/com/mailserver/service/DeliveryFolderTest.java new file mode 100644 index 0000000..2e14b89 --- /dev/null +++ b/backend/src/test/java/com/mailserver/service/DeliveryFolderTest.java @@ -0,0 +1,21 @@ +package com.mailserver.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** A "+tag" never puts mail into a system folder and never makes a strange folder name. */ +class DeliveryFolderTest { + + @Test + void onlyPlainOwnFolderNames() { + assertThat(MailService.deliveryFolderName("prod")).isEqualTo("prod"); + assertThat(MailService.deliveryFolderName("alerts.db-1")).isEqualTo("alerts.db-1"); + assertThat(MailService.deliveryFolderName(null)).isNull(); + assertThat(MailService.deliveryFolderName("inbox")).isNull(); + assertThat(MailService.deliveryFolderName("Trash")).isNull(); + assertThat(MailService.deliveryFolderName("spam")).isNull(); + assertThat(MailService.deliveryFolderName("../x")).isNull(); + assertThat(MailService.deliveryFolderName("a".repeat(51))).isNull(); + } +} diff --git a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java index ec20837..0e779c8 100644 --- a/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java +++ b/backend/src/test/java/com/mailserver/smtp/MailRouterTest.java @@ -7,6 +7,7 @@ import com.mailserver.domain.User; import com.mailserver.repository.UserRepository; import com.mailserver.service.DkimService; import com.mailserver.service.LocalDelivery; +import com.mailserver.service.MailLists; import com.mailserver.service.UserService; import org.junit.jupiter.api.Test; import reactor.core.publisher.Mono; @@ -18,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -30,6 +32,7 @@ class MailRouterTest { private final LocalDelivery localDelivery = mock(LocalDelivery.class); private final OutboundQueue queue = mock(OutboundQueue.class); private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class); + private final MailLists lists = mock(MailLists.class); private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF; @@ -46,9 +49,16 @@ class MailRouterTest { 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(users.existsByEmail("staging@mail.test")).thenReturn(Mono.just(true)); + when(users.existsByEmail("ann@mail.test")).thenReturn(Mono.just(true)); when(client.mode()).thenReturn(mode); + when(lists.find(anyString())).thenReturn(Mono.empty()); + when(lists.find("alerts-prod@mail.test")).thenReturn(Mono.just(new MailLists.MailList(1L, "alerts-prod@mail.test", + "prod", null, false, List.of("bob@mail.test", "ann@mail.test")))); + when(lists.find("talk@mail.test")).thenReturn(Mono.just(new MailLists.MailList(2L, "talk@mail.test", + null, null, true, List.of("bob@mail.test")))); return new MailRouter(new MailDomains("mail.test", catchAll), users, mock(UserService.class), localDelivery, - queue, mock(DkimService.class), client); + queue, mock(DkimService.class), client, lists); } @Test @@ -74,28 +84,70 @@ class MailRouterTest { 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("trap@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean()); - verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean()); + verify(localDelivery).deliver(any(), eq("trap@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean()); + verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean()); verify(queue, never()).enqueue(any(), any(), any()); } @Test - void trapMailboxSendsNowhereOneCopyLandsInItsOwnInbox() throws Exception { + void trapMailboxKeepsOutsideMailButOurAddressesStillGetIt() throws Exception { mode = SmtpDeliveryClient.Mode.MX; MailRouter router = router(""); - // an ordinary mailbox still sends outside; the trap one keeps everything + // an ordinary mailbox still sends outside; the trap one keeps outside mail, ours is delivered 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("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP); + assertThat(router.route("user@gmail.com", "Staging@Mail.test")).isEqualTo(SmtpBackend.Route.TRAP); + assertThat(router.route("nobody@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.TRAP); assertThat(router.route("bob@mail.test", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL); + assertThat(router.route("alerts-prod@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("user@gmail.com", SmtpBackend.Route.TRAP), + new SmtpBackend.Recipient("other@yahoo.com", SmtpBackend.Route.TRAP), 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(localDelivery).deliver(any(), eq("staging@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true)); + verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true)); verify(queue, never()).enqueue(any(), any(), any()); } + + @Test + void plusTagGoesToThatFolderOfTheMailbox() throws Exception { + MailRouter router = router(""); + + assertThat(router.route("bob+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL); + assertThat(router.route("nobody+prod@mail.test", null)).isEqualTo(SmtpBackend.Route.UNKNOWN_USER); + + router.accept(new SmtpBackend.Envelope("10.0.0.5", "x", "a@b.c", null, List.of( + new SmtpBackend.Recipient("Bob+Prod@mail.test", SmtpBackend.Route.LOCAL))), "Subject: hi\r\n\r\nbody".getBytes()); + + verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("Prod"), eq("10.0.0.5"), eq("a@b.c"), eq(false)); + } + + @Test + void listGivesEveryMemberOwnCopyInTheListFolderClosedListOnlyForLoggedIn() throws Exception { + MailRouter router = router(""); + + assertThat(router.route("alerts-prod@mail.test", null)).isEqualTo(SmtpBackend.Route.LIST_CLOSED); + assertThat(router.route("alerts-prod@mail.test", "monitor@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL); + assertThat(router.route("talk@mail.test", null)).isEqualTo(SmtpBackend.Route.LOCAL); + + // list + one member directly: the member gets the list copy AND the direct one (different folders), once each + router.accept(new SmtpBackend.Envelope("10.0.0.5", "kuma", "monitor@mail.test", "monitor@mail.test", List.of( + new SmtpBackend.Recipient("alerts-prod@mail.test", SmtpBackend.Route.LOCAL), + new SmtpBackend.Recipient("ALERTS-PROD@mail.test", SmtpBackend.Route.LOCAL), + new SmtpBackend.Recipient("bob@mail.test", SmtpBackend.Route.LOCAL))), "Subject: down\r\n\r\nbody".getBytes()); + + verify(localDelivery).deliver(any(), eq("bob@mail.test"), eq("prod"), any(), any(), eq(true)); + verify(localDelivery).deliver(any(), eq("ann@mail.test"), eq("prod"), any(), any(), eq(true)); + verify(localDelivery).deliver(any(), eq("bob@mail.test"), isNull(), any(), any(), eq(true)); + } + + @Test + void listTagOverridesTheListFolder() { + MailRouter router = router(""); + assertThat(router.targets("alerts-prod+db@mail.test")).containsExactly( + new MailRouter.Target("bob@mail.test", "db"), new MailRouter.Target("ann@mail.test", "db")); + } } diff --git a/docker-compose.image.yml b/docker-compose.image.yml index dd28002..3730100 100644 --- a/docker-compose.image.yml +++ b/docker-compose.image.yml @@ -19,7 +19,7 @@ services: - -c - | umask 077 - for name in DB_PASSWORD JWT_SECRET; do + for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name done # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only diff --git a/docker-compose.jambotron.yml b/docker-compose.jambotron.yml index 8fb0d4b..d61c445 100644 --- a/docker-compose.jambotron.yml +++ b/docker-compose.jambotron.yml @@ -16,7 +16,7 @@ services: - -c - | umask 077 - for name in DB_PASSWORD JWT_SECRET; do + for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name done # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only diff --git a/docker-compose.public.yml b/docker-compose.public.yml index d2769e9..d479088 100644 --- a/docker-compose.public.yml +++ b/docker-compose.public.yml @@ -35,7 +35,7 @@ services: - -c - | umask 077 - for name in DB_PASSWORD JWT_SECRET; do + for name in DB_PASSWORD JWT_SECRET SERVICE_TOKEN; do [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name done chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET diff --git a/frontend/src/app/core/api.service.ts b/frontend/src/app/core/api.service.ts index 874be36..e2807d7 100644 --- a/frontend/src/app/core/api.service.ts +++ b/frontend/src/app/core/api.service.ts @@ -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, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models'; +import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, Message, Profile, ServerConfig } from './models'; /** The mail server REST API (same origin, /api). */ @Injectable({ providedIn: 'root' }) @@ -28,6 +28,13 @@ export class ApiService { openMailbox(id: number): Observable<{ token: string; email: string }> { return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {}); } + mailLists(): Observable { return this.http.get('/api/admin/mailboxes/lists'); } + saveMailList(address: string, list: { folder: string; description: string; openToOutside: boolean; members: string[] }): Observable { + return this.http.put(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`, list); + } + deleteMailList(address: string): Observable { + return this.http.delete(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`); + } setMailboxTrap(id: number, trap: boolean): Observable { return this.http.put(`/api/admin/mailboxes/${id}/trap`, { trap }); } diff --git a/frontend/src/app/core/models.ts b/frontend/src/app/core/models.ts index 1ce6ab4..46cb9ca 100644 --- a/frontend/src/app/core/models.ts +++ b/frontend/src/app/core/models.ts @@ -69,6 +69,16 @@ export interface Mailbox { createdAt: string; } +/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */ +export interface MailList { + id: number; + address: string; + folder: string | null; + description: string | null; + openToOutside: boolean; + members: string[]; +} + /** password — generated by the server, shown once; null when the administrator chose it */ export interface MailboxCreated { mailbox: Mailbox; diff --git a/frontend/src/app/pages/settings/lists.section.ts b/frontend/src/app/pages/settings/lists.section.ts new file mode 100644 index 0000000..3accecc --- /dev/null +++ b/frontend/src/app/pages/settings/lists.section.ts @@ -0,0 +1,154 @@ +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 { MailList, Mailbox, errorText } from '../../core/models'; +import { ToastService } from '../../ui/toast.service'; + +/** + * Settings → LISTS (administrators): alerts-prod@ → a copy to every member, into the list's folder. + * Lists kept by Jambotron are edited here too, but Jambotron puts its own member set back on its next sync. + */ +@Component({ + selector: 'app-lists-section', + imports: [FormsModule, MatIconModule], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'block xl:col-span-2' }, + template: ` +
+

+ forward_to_inbox LISTS +

+

+ Mail to a list goes to every member — each gets their own copy (own read/unread) in the list's folder. + list+tag@ puts it into folder «tag» instead. Closed list: only logged-in senders (Kuma, Jambotron). +

+ +
+
+
+ + @ + {{ domain() }} +
+
+
+
+
+
+ Members +
+ @for (m of mailboxes(); track m.id) { + + } +
+
+
+ + + @if (editing()) { } + +
+
+ @if (error()) { } + +
    + @for (l of lists(); track l.id) { +
  • +
    + {{ l.address }} + → {{ l.folder || 'INBOX' }} + @if (l.openToOutside) { OPEN } + {{ l.description }} + + + +
    +
    {{ l.members.length ? l.members.join(', ') : 'no members — mail to it is dropped' }}
    +
  • + } @empty { +
  • No lists yet.
  • + } +
+
+ ` +}) +export class ListsSection { + private api = inject(ApiService); + private toasts = inject(ToastService); + + readonly lists = signal([]); + readonly mailboxes = signal([]); + readonly members = signal>(new Set()); + readonly local = signal(''); + readonly domains = signal([]); + readonly domain = computed(() => this.domains()[0] ?? ''); + readonly editing = signal(false); + readonly error = signal(''); + readonly busy = signal(false); + folder = ''; + description = ''; + openToOutside = false; + + constructor() { + this.api.config().subscribe({ next: c => this.domains.set(c.domains), error: () => {} }); + this.api.mailboxes().subscribe({ next: list => this.mailboxes.set(list), error: () => {} }); + this.load(); + } + + load(): void { + this.api.mailLists().subscribe({ next: list => this.lists.set(list), error: e => this.error.set(errorText(e)) }); + } + + flip(email: string): void { + const next = new Set(this.members()); + if (next.has(email)) { next.delete(email); } else { next.add(email); } + this.members.set(next); + } + + edit(l: MailList): void { + this.local.set(l.address.split('@')[0]); + this.domains.set([l.address.split('@')[1], ...this.domains().filter(d => d !== l.address.split('@')[1])]); + this.folder = l.folder ?? ''; + this.description = l.description ?? ''; + this.openToOutside = l.openToOutside; + this.members.set(new Set(l.members)); + this.editing.set(true); + this.error.set(''); + } + + reset(): void { + this.local.set(''); + this.folder = this.description = ''; + this.openToOutside = false; + this.members.set(new Set()); + this.editing.set(false); + } + + save(): void { + const local = this.local().replace(/@.*$/, ''); + if (!local) { this.error.set('Enter an address'); return; } + const address = local + '@' + this.domain(); + this.error.set(''); + this.busy.set(true); + this.api.saveMailList(address, { + folder: this.folder.trim(), description: this.description.trim(), openToOutside: this.openToOutside, + members: [...this.members()] + }).subscribe({ + next: saved => { this.busy.set(false); this.toasts.ok('List ' + saved.address + ' saved'); this.reset(); this.load(); }, + error: e => { this.busy.set(false); this.error.set(errorText(e)); } + }); + } + + remove(l: MailList): void { + if (!confirm('Delete list ' + l.address + '? Mail to it will be refused.')) return; + this.api.deleteMailList(l.address).subscribe({ + next: () => { this.toasts.ok('List ' + l.address + ' deleted'); this.load(); }, + error: e => this.toasts.error(errorText(e)) + }); + } +} diff --git a/frontend/src/app/pages/settings/settings.page.ts b/frontend/src/app/pages/settings/settings.page.ts index 7b9dc1f..4a73675 100644 --- a/frontend/src/app/pages/settings/settings.page.ts +++ b/frontend/src/app/pages/settings/settings.page.ts @@ -8,11 +8,12 @@ import { errorText, formatBytes } from '../../core/models'; import { THEMES, ThemeService } from '../../ui/theme.service'; import { ToastService } from '../../ui/toast.service'; import { MailboxesSection } from './mailboxes.section'; +import { ListsSection } from './lists.section'; /** Profile name, password, storage, look. */ @Component({ selector: 'app-settings', - imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection], + imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -113,6 +114,7 @@ import { MailboxesSection } from './mailboxes.section'; @if (state.profile()?.admin) { + }
` -- 2.54.0