Рассылки, папки по +метке, ловушка только для чужих адресов, служебное API для Jambotron
continuous-integration/drone/pr Build is passing
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:
@@ -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 <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 по решению владельца; сменили — обновить здесь).
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||
return admin(auth).then(body).flatMap(request -> {
|
||||
String email = request.email() == null ? "" : request.email().trim().toLowerCase(Locale.ROOT);
|
||||
int at = email.lastIndexOf('@');
|
||||
if (at <= 0 || !LOCAL_PART.matcher(email.substring(0, at)).matches()) {
|
||||
return Mono.error(bad("Not a mailbox address: " + email));
|
||||
}
|
||||
if (!domains.isLocal(email)) {
|
||||
return Mono.error(bad("Mailboxes can be created only in " + String.join(", ", domains.domains().stream().sorted().toList())));
|
||||
}
|
||||
String chosen = request.password() == null ? "" : request.password();
|
||||
if (!chosen.isEmpty() && chosen.length() < MIN_PASSWORD) {
|
||||
return Mono.error(bad("Password: at least " + MIN_PASSWORD + " characters, or leave it empty to generate one"));
|
||||
}
|
||||
String password = chosen.isEmpty() ? randomPassword() : chosen;
|
||||
return users.register(email, password, request.displayName())
|
||||
.onErrorMap(IllegalArgumentException.class, e -> new ResponseStatusException(HttpStatus.CONFLICT, "This mailbox already exists"))
|
||||
.doOnNext(user -> log.info("Mailbox created by administrator: {}", email))
|
||||
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<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) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
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());
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
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) {
|
||||
return folderRepository.findByUserIdAndName(userId, name)
|
||||
.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,
|
||||
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<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)
|
||||
.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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -74,6 +74,11 @@ public class UserService {
|
||||
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() {
|
||||
return userRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
* <ul>
|
||||
* <li>a mailbox of ours → that mailbox; {@code bob+prod@} → bob's folder "prod" (created on first mail);</li>
|
||||
* <li>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;</li>
|
||||
* <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
|
||||
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<Target> 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<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) {
|
||||
if (authenticatedUser == null) {
|
||||
return false;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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();
|
||||
|
||||
@@ -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.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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/trap`, { trap });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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@</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">@</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 { 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: `
|
||||
<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) {
|
||||
<app-mailboxes-section />
|
||||
<app-lists-section />
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user