Compare commits

...
Author SHA1 Message Date
Liosha84andClaude Opus 5.5 1076cd4f3f Селекты в тёмной теме: выпадающий список не белый
continuous-integration/drone/pr Build is passing
Открытый список <select> рисует браузер: без color-scheme он белый, а текст
наш светлый — не читается (домен в MAILBOXES, «Переместить», тема).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:02:28 +01:00
Liosha84 315402edc8 Merge pull request 'Администраторы почты из Jambotron: каждый ADMIN управляет ящиками' (#30) from feat/admins-from-jambotron into master
continuous-integration/drone/push Build is passing
Reviewed-on: #30
2026-11-07 19:46:46 +01:00
Liosha84andClaude Opus 5.5 7e09282a88 Администраторы почты из Jambotron: каждый ADMIN управляет ящиками
continuous-integration/drone/pr Build is passing
users.is_admin (V8) — ставит только служебное API (PUT /api/service/accounts
с "admin"), ящик сам себя администратором не сделает. MailAdmins: список из
конфигурации + выданные Jambotron (загружаются при старте, меняются сразу).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:44:32 +01:00
Liosha84 01c1e5504a Merge pull request 'Settings → MAILBOXES: удалить ящик вместе с письмами' (#29) from feat/delete-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #29
2026-11-07 18:49:23 +01:00
Liosha84andClaude Opus 5.5 e1bc0792ea Settings → MAILBOXES: удалить ящик вместе с письмами
continuous-integration/drone/pr Build is passing
DELETE /api/admin/mailboxes/{id} (только администратор, не свой ящик):
файлы вложений, затем строка users — папки, письма, исходники, контакты,
участие в рассылках и доступы уходят каскадом. Кнопка с подтверждением.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:48:09 +01:00
Liosha84 53c80c57d7 Merge pull request 'secrets-init: SERVICE_TOKEN читаемый для сервера (после #26 падал на старте)' (#28) from fix/service-token-readable into master
continuous-integration/drone/push Build is passing
Reviewed-on: #28
2026-11-07 18:20:45 +01:00
Liosha84andClaude Opus 5.5 7b7de905f9 secrets-init: SERVICE_TOKEN читаемый для сервера (как DB_PASSWORD, JWT_SECRET)
continuous-integration/drone/pr Build is passing
Без этого после #26 сервер не стартует: AccessDeniedException /run/mail-secrets/SERVICE_TOKEN
(на prod поправлено руками chmod 644 в томе 2026-11-07).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:27:23 +01:00
Liosha84 a26e8fe3dd Merge pull request 'Несколько ящиков у одного логина, общие ящики, «кто ответил», вход ящик*логин' (#27) from feat/multi-mailbox into master
continuous-integration/drone/push Build is passing
Reviewed-on: #27
2026-11-07 14:45:24 +01:00
Liosha84 80d2988fa3 Merge pull request 'Рассылки, папки по +метке, TRAP только для чужих адресов, служебное API для Jambotron' (#26) from feat/lists-tags-service-api into master
continuous-integration/drone/push Build is passing
Reviewed-on: #26
2026-11-07 14:45:12 +01:00
17 changed files with 169 additions and 17 deletions
@@ -10,13 +10,15 @@ import java.util.stream.Collectors;
/**
* Who manages mailboxes (Settings → Mailboxes): {@code mailserver.admins} (comma separated), by default the first
* mailbox — {@code mailserver.admin.email}, or admin@&lt;first domain&gt;. No role in the database: the list lives in
* the configuration, so a stolen mailbox can not make itself an administrator.
* mailbox — {@code mailserver.admin.email}, or admin@&lt;first domain&gt;; plus mailboxes Jambotron marks as admin
* through the service API ({@code users.is_admin}, V8 — every Jambotron ADMIN). A mailbox can not make itself an
* administrator: only the configuration or the service token.
*/
@Component
public class MailAdmins {
private final Set<String> admins;
private final Set<String> granted = java.util.concurrent.ConcurrentHashMap.newKeySet();
public MailAdmins(@Value("${mailserver.admins:}") String admins,
@Value("${mailserver.admin.email:}") String firstMailbox,
@@ -31,6 +33,20 @@ public class MailAdmins {
}
public boolean isAdmin(String email) {
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
if (email == null) {
return false;
}
String key = email.toLowerCase(Locale.ROOT);
return admins.contains(key) || granted.contains(key);
}
/** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */
public void grant(String email, boolean admin) {
String key = email.toLowerCase(Locale.ROOT);
if (admin) {
granted.add(key);
} else {
granted.remove(key);
}
}
}
@@ -0,0 +1,32 @@
package com.mailserver.config;
import com.mailserver.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */
@Component
public class MailAdminsLoader {
private static final Logger log = LoggerFactory.getLogger(MailAdminsLoader.class);
private final UserRepository users;
private final MailAdmins admins;
public MailAdminsLoader(UserRepository users, MailAdmins admins) {
this.users = users;
this.admins = admins;
}
@EventListener(ApplicationReadyEvent.class)
public void load() {
users.findAll().filter(user -> user.isAdmin())
.doOnNext(user -> admins.grant(user.getEmail(), true))
.count()
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage()));
}
}
@@ -158,6 +158,19 @@ public class MailboxAdminController {
}))).flatMap(this::view);
}
/** Deleted for good with all its mail (Settings → MAILBOXES → Delete); not your own mailbox. */
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
return admin(auth).flatMap(me -> mailbox(id).flatMap(user -> {
if (user.getEmail().equalsIgnoreCase(me)) {
return Mono.error(bad("You can not delete your own mailbox"));
}
return mail.deleteMailbox(user)
.doOnSuccess(v -> log.warn("Mailbox {} deleted with all its mail by administrator {}", user.getEmail(), me));
}));
}
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
@PutMapping("/{id}/trap")
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
@@ -40,10 +40,11 @@ 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) {
/** @param admin mail administrator (Jambotron ADMIN); null — leave as it is */
public record AccountRequest(@Size(max = 100) String displayName, Boolean active, Boolean admin) {
}
public record AccountView(String email, String displayName, boolean active, List<String> lists) {
public record AccountView(String email, String displayName, boolean active, boolean admin, List<String> lists) {
}
/** @param password generated, only when the account was just created or got a new password */
@@ -59,10 +60,12 @@ public class ServiceController {
private final MailLists lists;
private final MailDirectory directory;
private final MailboxAccess access;
private final com.mailserver.config.MailAdmins admins;
public ServiceController(@Value("${mailserver.service-token:}") String token, UserService users, MailLists lists,
MailDirectory directory, MailboxAccess access) {
MailDirectory directory, MailboxAccess access, com.mailserver.config.MailAdmins admins) {
this.access = access;
this.admins = admins;
this.token = token.isBlank() ? null : token.trim().getBytes(StandardCharsets.UTF_8);
this.users = users;
this.lists = lists;
@@ -84,13 +87,14 @@ public class ServiceController {
@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)
return body.defaultIfEmpty(new AccountRequest(null, 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))
.flatMap(user -> Boolean.FALSE.equals(request.active()) || Boolean.TRUE.equals(request.admin())
? 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)));
@@ -152,8 +156,14 @@ public class ServiceController {
}
private Mono<User> update(User user, AccountRequest request) {
Mono<User> named = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
Mono<User> renamed = request.displayName() == null || request.displayName().isBlank() ? Mono.just(user)
: users.updateDisplayName(user, request.displayName());
Mono<User> named = request.admin() == null || request.admin() == user.isAdmin() ? renamed
: renamed.flatMap(u -> users.setAdmin(u, request.admin()))
.doOnNext(u -> {
admins.grant(u.getEmail(), u.isAdmin());
log.info("Mailbox {} {} mail administrator by Jambotron", u.getEmail(), u.isAdmin() ? "made" : "is no longer");
});
if (request.active() == null || request.active() == user.isActive()) {
return named;
}
@@ -166,7 +176,7 @@ public class ServiceController {
private Mono<AccountView> view(User user) {
return lists.listsOf(user.getId()).collectList()
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), in));
.map(in -> new AccountView(user.getEmail(), user.getDisplayName(), user.isActive(), admins.isAdmin(user.getEmail()), in));
}
/** 404 when the API is off, 401 for a wrong token (constant-time compare). */
@@ -34,6 +34,10 @@ public class User {
@Column("is_trap")
private boolean isTrap = false;
/** Mail administrator given by Jambotron through the service API (V8); plus mailserver.admins. */
@Column("is_admin")
private boolean isAdmin = false;
/** Shared mailbox (V7): no login of its own, members open it. */
@Column("is_shared")
private boolean isShared = false;
@@ -64,6 +68,8 @@ public class User {
public boolean isTrap() { return isTrap; }
public void setTrap(boolean trap) { isTrap = trap; }
public boolean isAdmin() { return isAdmin; }
public void setAdmin(boolean admin) { isAdmin = admin; }
public boolean isShared() { return isShared; }
public void setShared(boolean shared) { isShared = shared; }
@@ -12,6 +12,10 @@ public interface AttachmentRepository extends R2dbcRepository<Attachment, Long>
Mono<Long> countByMessageId(Long messageId);
/** All attachments of a mailbox — their files are removed with the mailbox. */
@Query("SELECT a.* FROM attachments a JOIN messages m ON m.id = a.message_id JOIN folders f ON f.id = m.folder_id WHERE f.user_id = :#{#userId}")
Flux<Attachment> findByMailbox(Long userId);
@Modifying
@Query("UPDATE attachments SET message_id = :#{#to} WHERE message_id = :#{#from}")
Mono<Integer> moveAll(Long from, Long to);
@@ -401,6 +401,16 @@ public class MailService {
.then(messageRepository.delete(message));
}
/**
* Mailbox deleted for good: attachment files first, then the user row — folders, messages, sources, contacts,
* list memberships and access rows go with it (ON DELETE CASCADE).
*/
public Mono<Void> deleteMailbox(User user) {
return attachmentRepository.findByMailbox(user.getId())
.doOnNext(attachment -> deleteFile(attachment.getFilePath()))
.then(userRepository.delete(user));
}
private static void deleteFile(String path) {
try {
Files.deleteIfExists(Paths.get(path));
@@ -103,6 +103,11 @@ public class UserService {
return userRepository.save(user);
}
public Mono<User> setAdmin(User user, boolean admin) {
user.setAdmin(admin);
return userRepository.save(user);
}
public Mono<User> setTrap(User user, boolean trap) {
user.setTrap(trap);
return userRepository.save(user);
@@ -0,0 +1,3 @@
-- Mail administrator given by Jambotron (service API): every ADMIN of Jambotron manages mailboxes here too.
-- Only the service token sets it — a mailbox can not make itself an administrator. Plus mailserver.admins.
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
@@ -173,6 +173,19 @@ class MailboxAdminControllerTest {
.isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void administratorDeletesAMailboxButNotHisOwnOthersCanNot() {
when(mail.deleteMailbox(any())).thenReturn(Mono.empty());
controller.delete(as("admin@jambocheck.com"), 2L).block();
verify(mail).deleteMailbox(alice);
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(status(() -> controller.delete(as("alice@jambocheck.com"), 1L).block())).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(status(() -> controller.delete(as("admin@jambocheck.com"), 99L).block())).isEqualTo(HttpStatus.NOT_FOUND);
verify(mail, never()).deleteMailbox(admin);
}
@Test
void administratorOpensAnyMailboxOthersCanNot() {
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
@@ -34,6 +34,7 @@ class ServiceControllerTest {
private final com.mailserver.service.MailboxAccess access = mock(com.mailserver.service.MailboxAccess.class);
private final MailDirectory directory = new MailDirectory(users, lists, domains, access);
private final User existing = user(7L, "jambotronpw@jambocheck.com");
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("", "", domains);
ServiceControllerTest() {
when(users.findOptional(anyString())).thenReturn(Mono.empty());
@@ -49,10 +50,30 @@ class ServiceControllerTest {
return Mono.just(u);
});
when(users.setPassword(any(), anyString())).thenAnswer(i -> Mono.just((User) i.getArgument(0)));
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
User u = i.getArgument(0);
u.setAdmin(i.getArgument(1));
return Mono.just(u);
});
}
// Jambotron ADMIN → mail administrator (Settings → MAILBOXES), and back
@Test
void jambotronMakesAMailboxAdministratorAndTakesItBack() {
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
var made = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, true, true))).block();
assertThat(made.getBody().account().admin()).isTrue();
assertThat(admins.isAdmin("JambotronPW@jambocheck.com")).isTrue();
api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, true, false))).block();
assertThat(admins.isAdmin("jambotronpw@jambocheck.com")).isFalse();
assertThat(admins.isAdmin("admin@jambocheck.com")).isTrue();
}
private ServiceController api(String token) {
return new ServiceController(token, users, lists, directory, access);
return new ServiceController(token, users, lists, directory, access, admins);
}
@Test
@@ -67,12 +88,12 @@ class ServiceControllerTest {
@Test
void newAccountGetsPasswordOnceExistingDoesNot() {
var created = api("s3cret-token").saveAccount(AUTH, "moder@jambocheck.com",
Mono.just(new ServiceController.AccountRequest("Moder", true))).block();
Mono.just(new ServiceController.AccountRequest("Moder", true, null))).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();
Mono.just(new ServiceController.AccountRequest(null, null, null))).block();
assertThat(again.getStatusCode().value()).isEqualTo(200);
assertThat(again.getBody().password()).isNull();
verify(lists, never()).removeEverywhere(anyLong());
@@ -83,7 +104,7 @@ class ServiceControllerTest {
@Test
void switchedOffLeavesEveryList() {
var off = api("s3cret-token").saveAccount(AUTH, "jambotronpw@jambocheck.com",
Mono.just(new ServiceController.AccountRequest(null, false))).block();
Mono.just(new ServiceController.AccountRequest(null, false, null))).block();
assertThat(off.getBody().account().active()).isFalse();
verify(lists).removeEverywhere(7L);
verify(access).removeEverywhere(7L);
+1 -1
View File
@@ -23,7 +23,7 @@ services:
[ -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
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes:
- secrets:/secrets
+1 -1
View File
@@ -20,7 +20,7 @@ services:
[ -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
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes:
- secrets:/secrets
+1 -1
View File
@@ -38,7 +38,7 @@ services:
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
chmod 644 /secrets/DB_PASSWORD /secrets/JWT_SECRET /secrets/SERVICE_TOKEN
volumes:
- secrets:/secrets
+3
View File
@@ -55,6 +55,9 @@ export class ApiService {
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
}
deleteMailbox(id: number): Observable<void> {
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
}
// folders
folders(): Observable<Folder[]> { return this.http.get<Folder[]>('/api/folders'); }
@@ -91,6 +91,9 @@ import { AuthService } from '../../core/auth.service';
<button type="button" class="ghost-btn !py-1" (click)="toggle(m)"
[attr.aria-label]="(m.active ? 'Switch off ' : 'Switch on ') + m.email">
<mat-icon>{{ m.active ? 'toggle_on' : 'toggle_off' }}</mat-icon></button>
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(m)"
[attr.aria-label]="'Delete ' + m.email" title="Delete the mailbox with all its mail">
<mat-icon>delete</mat-icon></button>
}
@if (membersOf() === m.id) {
<div class="basis-full mt-1 p-2 rounded-lg border border-cyan-500/20">
@@ -227,6 +230,14 @@ export class MailboxesSection {
});
}
remove(m: Mailbox): void {
if (!confirm('Delete ' + m.email + ' with ALL its mail? This can not be undone.')) return;
this.api.deleteMailbox(m.id).subscribe({
next: () => { this.toasts.ok(m.email + ' deleted'); this.load(); },
error: e => this.toasts.error(errorText(e))
});
}
copy(text: string): void {
navigator.clipboard?.writeText(text).then(() => this.toasts.ok('Copied'), () => this.toasts.error('Copy it by hand'));
}
+5
View File
@@ -300,6 +300,11 @@ body {
}
.synth-input:focus { outline: none; border-color: var(--primary-color); box-shadow: 0 0 0 2px rgba(255, 0, 127, 0.45); }
.synth-input::placeholder { color: #64748b; }
/* Selects: the opened list is drawn by the browser — without this it is white (light OS theme) with our light text */
select { color-scheme: dark; }
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
select option:checked { background-color: var(--primary-color); color: #020617; }
.high-contrast select option { background-color: #000000; color: #ffffff; }
.synth-label { display: block; font-family: 'Fira Code', monospace; font-size: 0.72rem; color: var(--primary-color); margin-bottom: 0.3rem; letter-spacing: 0.04em; }
.ghost-btn {