Merge pull request 'Settings → MAILBOXES: удалить ящик вместе с письмами' (#29) from feat/delete-mailbox into master
continuous-integration/drone/push Build is passing

Reviewed-on: #29
This commit was merged in pull request #29.
This commit is contained in:
2026-11-07 18:49:23 +01:00
6 changed files with 54 additions and 0 deletions
@@ -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) {
@@ -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));
@@ -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");
+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'));
}