Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 14:15:24 +01:00
co-authored by Claude Opus 5.5
parent 3603b9d315
commit cf84797e68
28 changed files with 1013 additions and 71 deletions
+8 -1
View File
@@ -8,7 +8,14 @@
**Регистрации нет:** ящики создаёт администратор `admin@jambocheck.com` — веб-почта → Настройки → **MAILBOXES**. **Регистрации нет:** ящики создаёт администратор `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 <SERVICE_TOKEN>`; токен генерирует `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 по решению владельца; сменили — обновить здесь). **Администратор:** `admin@jambocheck.com`, пароль `eK-6oj3TMtZgoXVxlvtCWFI6` (начальный, записан 2026-11-07 по решению владельца; сменили — обновить здесь).
@@ -44,6 +44,24 @@ public class MailDomains {
return domains; 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) { public static String domainOf(String address) {
int at = address == null ? -1 : address.lastIndexOf('@'); int at = address == null ? -1 : address.lastIndexOf('@');
return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT); return at < 0 ? "" : address.substring(at + 1).toLowerCase(Locale.ROOT);
@@ -52,6 +52,8 @@ public class SecurityConfig {
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll() .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() .pathMatchers("/api/**", "/actuator/**").authenticated()
// the web client (static files, SPA routes) // the web client (static files, SPA routes)
.anyExchange().permitAll() .anyExchange().permitAll()
@@ -4,6 +4,8 @@ import com.mailserver.config.MailAdmins;
import com.mailserver.config.MailDomains; import com.mailserver.config.MailDomains;
import com.mailserver.domain.User; import com.mailserver.domain.User;
import com.mailserver.dto.LoginResponse; import com.mailserver.dto.LoginResponse;
import com.mailserver.service.MailDirectory;
import com.mailserver.service.MailLists;
import com.mailserver.service.MailService; import com.mailserver.service.MailService;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import com.mailserver.util.JwtUtils; import com.mailserver.util.JwtUtils;
@@ -13,6 +15,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; 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.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.Base64;
import java.util.Comparator; import java.util.Comparator;
import java.util.Locale; import java.util.List;
import java.util.regex.Pattern;
/** /**
* Mailboxes of this server for its administrators ({@link MailAdmins}): list, create (no-reply@, monitor@, * 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 { public class MailboxAdminController {
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class); 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 = MailDirectory.MIN_PASSWORD;
private static final int MIN_PASSWORD = 8;
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap, public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
long usedBytes, long quotaBytes, Instant createdAt) { long usedBytes, long quotaBytes, Instant createdAt) {
@@ -72,9 +71,14 @@ public class MailboxAdminController {
private final MailDomains domains; private final MailDomains domains;
private final MailAdmins admins; private final MailAdmins admins;
private final JwtUtils jwt; 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.jwt = jwt;
this.directory = directory;
this.lists = lists;
this.users = users; this.users = users;
this.mail = mail; this.mail = mail;
this.domains = domains; this.domains = domains;
@@ -92,22 +96,13 @@ public class MailboxAdminController {
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) { public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
return admin(auth).then(body).flatMap(request -> { 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(); String chosen = request.password() == null ? "" : request.password();
if (!chosen.isEmpty() && chosen.length() < MIN_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")); return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
} }
String password = chosen.isEmpty() ? randomPassword() : chosen; String password = chosen.isEmpty() ? randomPassword() : chosen;
return users.register(email, password, request.displayName()) return directory.createMailbox(request.email(), request.displayName(), password)
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists")) .doOnNext(user -> log.info("Mailbox created by administrator: {}", user.getEmail()))
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
.flatMap(this::view) .flatMap(this::view)
.map(view -> new Created(view, chosen.isEmpty() ? password : null)); .map(view -> new Created(view, chosen.isEmpty() ? password : null));
}); });
@@ -166,6 +161,35 @@ public class MailboxAdminController {
}))).flatMap(this::view); }))).flatMap(this::view);
} }
// --- Mailing lists (Settings → LISTS) ---
public record ListRequest(@Size(max = 50) String folder, @Size(max = 255) String description, boolean openToOutside,
List<String> members) {
}
@GetMapping("/lists")
public Flux<MailLists.MailList> lists(Mono<Authentication> auth) {
return admin(auth).thenMany(lists.all());
}
/** Creates or replaces the list {@code address} (settings + the exact member set). */
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(Mono<Authentication> auth, @PathVariable String address,
@RequestBody Mono<ListRequest> 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<Void> deleteList(Mono<Authentication> auth, @PathVariable String address) {
return admin(auth).flatMap(me -> lists.delete(address)
.flatMap(deleted -> deleted ? Mono.<Void>empty()
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "List not found")))
.doOnSuccess(v -> log.info("List {} deleted by administrator {}", address, me)));
}
private Mono<String> admin(Mono<Authentication> auth) { private Mono<String> admin(Mono<Authentication> auth) {
return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED))) return auth.switchIfEmpty(Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED)))
.map(Authentication::getName) .map(Authentication::getName)
@@ -189,8 +213,6 @@ public class MailboxAdminController {
} }
static String randomPassword() { static String randomPassword() {
byte[] bytes = new byte[18]; return MailDirectory.randomPassword();
new SecureRandom().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
} }
} }
@@ -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<String> 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<String> 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<AccountView> 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<ResponseEntity<AccountResult>> saveAccount(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String email, @RequestBody Mono<AccountRequest> 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<AccountResult> 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<MailLists.MailList> lists(@RequestHeader(value = "Authorization", required = false) String auth) {
check(auth);
return lists.all();
}
@PutMapping("/lists/{address}")
public Mono<MailLists.MailList> saveList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address, @RequestBody Mono<ListRequest> 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<ResponseEntity<Void>> deleteList(@RequestHeader(value = "Authorization", required = false) String auth,
@PathVariable String address) {
check(auth);
return lists.delete(address).map(deleted -> deleted
? ResponseEntity.noContent().<Void>build() : ResponseEntity.notFound().<Void>build());
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> 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<AccountView> 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);
}
}
}
@@ -54,6 +54,12 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
/** Blocking: call from a worker thread. */ /** Blocking: call from a worker thread. */
public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted) public void deliver(byte[] raw, String mailbox, String remoteIp, String envelopeFrom, boolean trusted)
throws MessagingException, IOException { 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)); MimeMessage mime = new MimeMessage(Session.getInstance(new Properties()), new ByteArrayInputStream(raw));
String senderEmail = envelopeFrom; String senderEmail = envelopeFrom;
@@ -80,7 +86,7 @@ public class LocalDelivery implements DeliveryWorker.BounceSender {
com.mailserver.domain.Message stored = com.mailserver.domain.Message stored =
mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam, mailService.receiveMessage(senderEmail, senderName, mailbox, subject, body.text(), body.html(), spam,
addresses(mime, jakarta.mail.Message.RecipientType.TO), 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) { if (stored != null) {
for (FilePart file : attachments(mime)) { for (FilePart file : attachments(mime)) {
mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes()); mailService.storeIncomingAttachment(stored.getId(), file.name(), file.contentType(), file.bytes());
@@ -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<User> 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.<User>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<MailLists.MailList> saveList(String rawAddress, String rawFolder, String description,
boolean openToOutside, List<String> 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<String> 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<String> 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);
}
}
@@ -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<String> members) {
}
private final DatabaseClient db;
public MailLists(DatabaseClient db) {
this.db = db;
}
public Mono<MailList> 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<Boolean> 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<MailList> 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<String> 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<MailList> save(String address, String folder, String description, boolean openToOutside,
Collection<Long> 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<Boolean> 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<Long> removeEverywhere(Long userId) {
return db.sql("DELETE FROM mail_list_members WHERE user_id = :u").bind("u", userId).fetch().rowsUpdated();
}
private Mono<MailList> 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));
}
}
@@ -162,6 +162,34 @@ public class MailService {
.switchIfEmpty(Mono.error(badRequest("System folders cannot be renamed or deleted"))); .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<Folder> deliveryFolder(Long userId, String name) {
Mono<Folder> 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<Folder> systemFolder(Long userId, String name) { private Mono<Folder> systemFolder(Long userId, String name) {
return folderRepository.findByUserIdAndName(userId, name) return folderRepository.findByUserIdAndName(userId, name)
.switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found"))); .switchIfEmpty(Mono.error(new IllegalStateException(name + " folder not found")));
@@ -253,12 +281,25 @@ public class MailService {
public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail, public Mono<Message> receiveMessage(String senderEmail, String senderName, String recipientEmail,
String subject, String body, boolean isHtml, boolean externalSpamFlag, String subject, String body, boolean isHtml, boolean externalSpamFlag,
String toHeader, String ccHeader) { 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<Message> 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) return userRepository.findByEmail(recipientEmail)
.switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail))) .switchIfEmpty(Mono.error(new IllegalArgumentException("Recipient not found: " + recipientEmail)))
.flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail) .flatMap(recipient -> spamFilterService.isBlacklisted(recipient.getId(), senderEmail)
.flatMap(blacklisted -> { .flatMap(blacklisted -> {
boolean isSpam = blacklisted || externalSpamFlag; 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 -> { .flatMap(folder -> {
Message message = new Message(); Message message = new Message();
message.setFolderId(folder.getId()); message.setFolderId(folder.getId());
@@ -88,10 +88,10 @@ public class SubmissionService {
for (String address : concat(to, cc)) { for (String address : concat(to, cc)) {
SmtpBackend.Route route = router.route(address, user.getEmail()); SmtpBackend.Route route = router.route(address, user.getEmail());
switch (route) { 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 UNKNOWN_USER -> throw MailService.badRequest("No such mailbox: " + address);
case OUTBOUND_DISABLED -> throw MailService.badRequest("Sending outside is disabled on this server: " + 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; String messageId = UUID.randomUUID() + "@" + hostname;
@@ -74,6 +74,11 @@ public class UserService {
return userRepository.save(user).then(); return userRepository.save(user).then();
} }
/** The mailbox, or empty (no error). */
public Mono<User> findOptional(String email) {
return userRepository.findByEmail(email.toLowerCase(java.util.Locale.ROOT));
}
public Flux<User> all() { public Flux<User> all() {
return userRepository.findAll(); return userRepository.findAll();
} }
@@ -7,18 +7,25 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.LinkedHashSet;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
/** /**
* Where mail goes — one rule for SMTP and for the web client: * 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 * <ul>
* (nothing goes outside, existing mailboxes still get their own mail); otherwise our domain without such a mailbox → * <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
* "no such user", someone else's domain → the delivery queue, only for a logged-in sender (never an open relay). * <li>a mailing list of ours ({@code alerts-prod@}) → a copy to every member, into the list's folder
* A logged-in TRAP mailbox (staging@) sends nowhere: every recipient is accepted and ONE copy is stored in the * (or the "+tag" folder); a closed list takes mail only from logged-in senders;</li>
* sender's own INBOX — a staging stand uses the real server, but its test mail never reaches people. * <li>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);</li>
* <li>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).</li>
* </ul>
*/ */
@Service @Service
public class MailRouter implements SmtpBackend { public class MailRouter implements SmtpBackend {
@@ -30,9 +37,10 @@ public class MailRouter implements SmtpBackend {
private final OutboundQueue queue; private final OutboundQueue queue;
private final DkimService dkim; private final DkimService dkim;
private final SmtpDeliveryClient client; private final SmtpDeliveryClient client;
private final MailLists lists;
public MailRouter(MailDomains domains, UserRepository users, UserService userService, LocalDelivery localDelivery, 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.domains = domains;
this.users = users; this.users = users;
this.userService = userService; this.userService = userService;
@@ -40,6 +48,7 @@ public class MailRouter implements SmtpBackend {
this.queue = queue; this.queue = queue;
this.dkim = dkim; this.dkim = dkim;
this.client = client; this.client = client;
this.lists = lists;
} }
@Override @Override
@@ -50,11 +59,18 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public Route route(String recipient, String authenticatedUser) { public Route route(String recipient, String authenticatedUser) {
if (isTrap(authenticatedUser)) { if (domains.isLocal(recipient)) {
return Route.LOCAL; 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)) { if (isTrap(authenticatedUser)) {
return Route.LOCAL; return Route.TRAP;
} }
if (domains.catchAll() != null) { if (domains.catchAll() != null) {
return Route.LOCAL; return Route.LOCAL;
@@ -71,18 +87,13 @@ public class MailRouter implements SmtpBackend {
@Override @Override
public void accept(Envelope envelope, byte[] message) throws Exception { public void accept(Envelope envelope, byte[] message) throws Exception {
boolean trusted = envelope.authenticatedUser() != null; boolean trusted = envelope.authenticatedUser() != null;
if (isTrap(envelope.authenticatedUser())) { // one copy per mailbox + folder, even when an address and a list both lead there
localDelivery.deliver(message, envelope.authenticatedUser().toLowerCase(Locale.ROOT), envelope.remoteIp(), Set<Target> targets = new LinkedHashSet<>();
envelope.mailFrom(), true);
return;
}
byte[] signed = null; byte[] signed = null;
for (Recipient recipient : envelope.recipients()) { for (Recipient recipient : envelope.recipients()) {
switch (recipient.route()) { switch (recipient.route()) {
case LOCAL -> { case LOCAL -> targets.addAll(targets(recipient.address()));
String mailbox = mailbox(recipient.address()); case TRAP -> targets.add(new Target(envelope.authenticatedUser().toLowerCase(Locale.ROOT), null));
localDelivery.deliver(message, mailbox, envelope.remoteIp(), envelope.mailFrom(), trusted);
}
case OUTBOUND -> { case OUTBOUND -> {
if (signed == null) { if (signed == null) {
signed = dkim.sign(message); signed = dkim.sign(message);
@@ -92,17 +103,50 @@ public class MailRouter implements SmtpBackend {
default -> throw new IllegalStateException("Recipient was not accepted: " + recipient); default -> throw new IllegalStateException("Recipient was not accepted: " + recipient);
} }
} }
} for (Target target : targets) {
localDelivery.deliver(message, target.mailbox(), target.folder(), envelope.remoteIp(), envelope.mailFrom(), trusted);
/** 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;
} }
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<Target> targets(String address) {
Set<Target> 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<Target> found = targets(address);
return found.isEmpty() ? address : found.iterator().next().mailbox();
}
/** The logged-in sender is a trap mailbox. */
boolean isTrap(String authenticatedUser) { boolean isTrap(String authenticatedUser) {
if (authenticatedUser == null) { if (authenticatedUser == null) {
return false; return false;
@@ -10,8 +10,12 @@ public interface SmtpBackend {
/** Where a recipient goes, decided at RCPT TO. */ /** Where a recipient goes, decided at RCPT TO. */
enum Route { 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, 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. */ /** Someone else's address, sender is logged in — queue for delivery. */
OUTBOUND, OUTBOUND,
/** Address of our domain that does not exist. */ /** Address of our domain that does not exist. */
@@ -438,7 +438,7 @@ public class SmtpServer {
String user = authenticatedUser; String user = authenticatedUser;
return blocking(() -> backend.route(address, user)) return blocking(() -> backend.route(address, user))
.map(route -> switch (route) { .map(route -> switch (route) {
case LOCAL, OUTBOUND -> { case LOCAL, OUTBOUND, TRAP -> {
recipients.add(new SmtpBackend.Recipient(address, route)); recipients.add(new SmtpBackend.Recipient(address, route));
yield "250 2.1.5 Recipient OK\r\n"; 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"; 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 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";
}); });
} }
@@ -31,6 +31,9 @@ mailserver.admin.password=${MAIL_ADMIN_PASSWORD:}
mailserver.admins=${MAIL_ADMINS:} 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:}
# 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:} mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
# Attachments # Attachments
@@ -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);
@@ -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();
}
}
@@ -34,13 +34,22 @@ class MailboxAdminControllerTest {
private final MailDomains domains = new MailDomains("jambocheck.com", ""); private final MailDomains domains = new MailDomains("jambocheck.com", "");
private final MailAdmins admins = new MailAdmins("", "", domains); private final MailAdmins admins = new MailAdmins("", "", domains);
private final com.mailserver.util.JwtUtils jwt = mock(com.mailserver.util.JwtUtils.class); 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 admin = user(1L, "admin@jambocheck.com");
private final User alice = user(2L, "alice@jambocheck.com"); private final User alice = user(2L, "alice@jambocheck.com");
MailboxAdminControllerTest() { MailboxAdminControllerTest() {
when(mail.usedBytes(anyLong())).thenReturn(Mono.just(0L)); 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.all()).thenReturn(Flux.just(alice, admin));
when(users.byId(1L)).thenReturn(Mono.just(admin)); when(users.byId(1L)).thenReturn(Mono.just(admin));
when(users.byId(2L)).thenReturn(Mono.just(alice)); when(users.byId(2L)).thenReturn(Mono.just(alice));
@@ -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 @Test
void administratorSwitchesTrapOthersCanNot() { void administratorSwitchesTrapOthersCanNot() {
assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue(); assertThat(controller.trap(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.TrapRequest(true))).block().trap()).isTrue();
@@ -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;
}
}
@@ -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();
}
}
@@ -7,6 +7,7 @@ import com.mailserver.domain.User;
import com.mailserver.repository.UserRepository; import com.mailserver.repository.UserRepository;
import com.mailserver.service.DkimService; import com.mailserver.service.DkimService;
import com.mailserver.service.LocalDelivery; import com.mailserver.service.LocalDelivery;
import com.mailserver.service.MailLists;
import com.mailserver.service.UserService; import com.mailserver.service.UserService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono; 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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -30,6 +32,7 @@ class MailRouterTest {
private final LocalDelivery localDelivery = mock(LocalDelivery.class); private final LocalDelivery localDelivery = mock(LocalDelivery.class);
private final OutboundQueue queue = mock(OutboundQueue.class); private final OutboundQueue queue = mock(OutboundQueue.class);
private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class); private final SmtpDeliveryClient client = mock(SmtpDeliveryClient.class);
private final MailLists lists = mock(MailLists.class);
private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF; private SmtpDeliveryClient.Mode mode = SmtpDeliveryClient.Mode.OFF;
@@ -46,9 +49,16 @@ class MailRouterTest {
when(users.findByEmail(anyString())).thenReturn(Mono.empty()); when(users.findByEmail(anyString())).thenReturn(Mono.empty());
when(users.findByEmail("bob@mail.test")).thenReturn(Mono.just(user("bob@mail.test", false))); 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.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(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, 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 @Test
@@ -74,28 +84,70 @@ class MailRouterTest {
new SmtpBackend.Recipient("user@gmail.com", SmtpBackend.Route.LOCAL), 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()); 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("trap@mail.test"), isNull(), 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("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("no-reply@jambotron.com"), anyBoolean());
verify(queue, never()).enqueue(any(), any(), any()); verify(queue, never()).enqueue(any(), any(), any());
} }
@Test @Test
void trapMailboxSendsNowhereOneCopyLandsInItsOwnInbox() throws Exception { void trapMailboxKeepsOutsideMailButOurAddressesStillGetIt() throws Exception {
mode = SmtpDeliveryClient.Mode.MX; mode = SmtpDeliveryClient.Mode.MX;
MailRouter router = router(""); 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", "bob@mail.test")).isEqualTo(SmtpBackend.Route.OUTBOUND);
assertThat(router.route("user@gmail.com", "staging@mail.test")).isEqualTo(SmtpBackend.Route.LOCAL); 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.LOCAL); 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("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( 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()); 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).deliver(any(), eq("staging@mail.test"), isNull(), 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("bob@mail.test"), isNull(), eq("10.0.0.5"), eq("staging@mail.test"), eq(true));
verify(queue, never()).enqueue(any(), any(), any()); 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"));
}
} }
+1 -1
View File
@@ -19,7 +19,7 @@ services:
- -c - -c
- | - |
umask 077 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 [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
+1 -1
View File
@@ -16,7 +16,7 @@ services:
- -c - -c
- | - |
umask 077 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 [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
# postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only # postgres reads it as uid 70, the app as uid 10001 — readable inside these containers only
+1 -1
View File
@@ -35,7 +35,7 @@ services:
- -c - -c
- | - |
umask 077 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 [ -s /secrets/$$name ] || head -c 48 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > /secrets/$$name
done done
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
+8 -1
View File
@@ -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, 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). */ /** The mail server REST API (same origin, /api). */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -28,6 +28,13 @@ export class ApiService {
openMailbox(id: number): Observable<{ token: string; email: string }> { openMailbox(id: number): Observable<{ token: string; email: string }> {
return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {}); return this.http.post<{ token: string; email: string }>(`/api/admin/mailboxes/${id}/open`, {});
} }
mailLists(): Observable<MailList[]> { return this.http.get<MailList[]>('/api/admin/mailboxes/lists'); }
saveMailList(address: string, list: { folder: string; description: string; openToOutside: boolean; members: string[] }): Observable<MailList> {
return this.http.put<MailList>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`, list);
}
deleteMailList(address: string): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/lists/${encodeURIComponent(address)}`);
}
setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> { setMailboxTrap(id: number, trap: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap }); return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
} }
+10
View File
@@ -69,6 +69,16 @@ export interface Mailbox {
createdAt: string; 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 */ /** password — generated by the server, shown once; null when the administrator chose it */
export interface MailboxCreated { export interface MailboxCreated {
mailbox: Mailbox; mailbox: Mailbox;
@@ -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: `
<section class="glass-panel static p-5 md:p-6" aria-labelledby="lists-title">
<h2 id="lists-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-2 border-b border-cyan-500/20 pb-2">
<mat-icon class="text-pink-500">forward_to_inbox</mat-icon> LISTS
</h2>
<p class="text-xs text-slate-400 mb-4">
Mail to a list goes to every member — each gets their own copy (own read/unread) in the list's folder.
<span class="font-mono">list+tag&#64;</span> puts it into folder «tag» instead. Closed list: only logged-in senders (Kuma, Jambotron).
</p>
<form (ngSubmit)="save()" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_1fr] gap-3 mb-3">
<div><label class="synth-label" for="ls-address">Address</label>
<div class="flex items-stretch gap-2">
<input id="ls-address" name="address" class="synth-input" [ngModel]="local()" (ngModelChange)="local.set($event.toLowerCase().trim())"
placeholder="alerts-prod" autocomplete="off" required [readonly]="editing()">
<span class="flex items-center text-cyan-300 font-mono">&#64;</span>
<span class="flex items-center px-3 rounded-[10px] bg-slate-900/70 border border-cyan-500/30 font-mono text-sm text-slate-200 whitespace-nowrap">{{ domain() }}</span>
</div></div>
<div><label class="synth-label" for="ls-folder">Folder (empty — INBOX)</label>
<input id="ls-folder" name="folder" class="synth-input" [(ngModel)]="folder" maxlength="50" placeholder="prod" autocomplete="off"></div>
<div><label class="synth-label" for="ls-about">Description</label>
<input id="ls-about" name="about" class="synth-input" [(ngModel)]="description" maxlength="255" autocomplete="off"></div>
<fieldset class="md:col-span-3">
<legend class="synth-label">Members</legend>
<div class="flex flex-wrap gap-x-4 gap-y-1">
@for (m of mailboxes(); track m.id) {
<label class="flex items-center gap-1.5 text-sm font-mono" [class.text-slate-500]="!m.active">
<input type="checkbox" [checked]="members().has(m.email)" (change)="flip(m.email)"> {{ m.email }}
</label>
}
</div>
</fieldset>
<div class="md:col-span-3 flex flex-wrap items-center gap-3">
<label class="flex items-center gap-1.5 text-sm"><input type="checkbox" name="open" [(ngModel)]="openToOutside"> open to anyone (not only logged-in senders)</label>
<span class="ml-auto"></span>
@if (editing()) { <button type="button" class="ghost-btn !py-1" (click)="reset()"><mat-icon>close</mat-icon> CANCEL</button> }
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold" [disabled]="busy()">
<mat-icon>{{ editing() ? 'save' : 'add' }}</mat-icon> {{ editing() ? 'SAVE' : 'CREATE' }}</button>
</div>
</form>
@if (error()) { <p class="text-sm text-pink-400 mb-3" role="alert">{{ error() }}</p> }
<ul class="divide-y divide-cyan-500/10">
@for (l of lists(); track l.id) {
<li class="py-2">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="font-mono text-sm text-cyan-300">{{ l.address }}</span>
<span class="text-xs font-mono text-slate-400">→ {{ l.folder || 'INBOX' }}</span>
@if (l.openToOutside) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300">OPEN</span> }
<span class="text-sm text-slate-400">{{ l.description }}</span>
<span class="ml-auto"></span>
<button type="button" class="ghost-btn !py-1" (click)="edit(l)" [attr.aria-label]="'Edit ' + l.address"><mat-icon>edit</mat-icon></button>
<button type="button" class="ghost-btn !py-1" (click)="remove(l)" [attr.aria-label]="'Delete ' + l.address"><mat-icon>delete</mat-icon></button>
</div>
<div class="text-xs font-mono text-slate-500 mt-0.5">{{ l.members.length ? l.members.join(', ') : 'no members — mail to it is dropped' }}</div>
</li>
} @empty {
<li class="py-2 text-sm text-slate-500">No lists yet.</li>
}
</ul>
</section>
`
})
export class ListsSection {
private api = inject(ApiService);
private toasts = inject(ToastService);
readonly lists = signal<MailList[]>([]);
readonly mailboxes = signal<Mailbox[]>([]);
readonly members = signal<Set<string>>(new Set());
readonly local = signal('');
readonly domains = signal<string[]>([]);
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))
});
}
}
@@ -8,11 +8,12 @@ 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'; import { MailboxesSection } from './mailboxes.section';
import { ListsSection } from './lists.section';
/** Profile name, password, storage, look. */ /** Profile name, password, storage, look. */
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection], imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
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">
@@ -113,6 +114,7 @@ import { MailboxesSection } from './mailboxes.section';
@if (state.profile()?.admin) { @if (state.profile()?.admin) {
<app-mailboxes-section /> <app-mailboxes-section />
<app-lists-section />
} }
</div> </div>
` `