Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf87b60b38 | ||
|
|
402d2c403b | ||
|
|
8eb69b32e5 | ||
|
|
3d9fc77201 | ||
|
|
1076cd4f3f | ||
|
|
315402edc8 | ||
|
|
7e09282a88 | ||
|
|
01c1e5504a | ||
|
|
e1bc0792ea | ||
|
|
53c80c57d7 |
@@ -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@<first domain>. 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@<first domain>; 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,25 @@ public class MailAdmins {
|
||||
}
|
||||
|
||||
public boolean isAdmin(String email) {
|
||||
if (email == null) {
|
||||
return false;
|
||||
}
|
||||
String key = email.toLowerCase(Locale.ROOT);
|
||||
return admins.contains(key) || granted.contains(key);
|
||||
}
|
||||
|
||||
/** Built-in administrator from the configuration (admin@ by default): the role can not be taken away. */
|
||||
public boolean isBuiltIn(String email) {
|
||||
return email != null && admins.contains(email.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/** Role ADMIN in users.is_admin (given by an administrator in Admin panel or by Jambotron) — loaded at start, 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,36 @@
|
||||
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;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/** At start: role ADMIN (users.is_admin) → {@link MailAdmins}; the built-in administrator gets the role in the database too. */
|
||||
@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()
|
||||
.concatMap(user -> !user.isAdmin() && admins.isBuiltIn(user.getEmail())
|
||||
? Mono.fromSupplier(() -> { user.setAdmin(true); return user; }).flatMap(users::save) : Mono.just(user))
|
||||
.filter(user -> user.isAdmin())
|
||||
.doOnNext(user -> admins.grant(user.getEmail(), true))
|
||||
.count()
|
||||
.subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
|
||||
e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,13 @@ public class MailboxAdminController {
|
||||
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
|
||||
|
||||
/** @param role ADMIN or USER; @param builtIn the administrator from the configuration — the role stays */
|
||||
public record MailboxView(Long id, String email, String displayName, boolean active, boolean admin, boolean trap,
|
||||
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt) {
|
||||
boolean shared, List<String> members, long usedBytes, long quotaBytes, Instant createdAt,
|
||||
String role, boolean builtIn) {
|
||||
}
|
||||
|
||||
public record RoleRequest(String role) {
|
||||
}
|
||||
|
||||
/** @param password empty — the server generates one and returns it once */
|
||||
@@ -158,6 +163,43 @@ 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));
|
||||
}));
|
||||
}
|
||||
|
||||
/** Role ADMIN (manages mailboxes, lists, roles — Admin panel) or USER. Not your own, not the built-in administrator. */
|
||||
@PutMapping("/{id}/role")
|
||||
public Mono<MailboxView> role(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<RoleRequest> body) {
|
||||
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||
if (!"ADMIN".equals(request.role()) && !"USER".equals(request.role())) {
|
||||
return Mono.error(bad("Role: ADMIN or USER"));
|
||||
}
|
||||
boolean toAdmin = "ADMIN".equals(request.role());
|
||||
if (user.getEmail().equalsIgnoreCase(me)) {
|
||||
return Mono.error(bad("You can not change your own role"));
|
||||
}
|
||||
if (!toAdmin && admins.isBuiltIn(user.getEmail())) {
|
||||
return Mono.error(bad("The built-in administrator stays ADMIN"));
|
||||
}
|
||||
if (toAdmin && user.isShared()) {
|
||||
return Mono.error(bad("A shared mailbox has no login of its own — it can not be ADMIN"));
|
||||
}
|
||||
return users.setAdmin(user, toAdmin).doOnNext(u -> {
|
||||
admins.grant(u.getEmail(), toAdmin);
|
||||
log.warn("Mailbox {} role {} given by administrator {}", u.getEmail(), request.role(), me);
|
||||
});
|
||||
}))).flatMap(this::view);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
@@ -224,7 +266,7 @@ public class MailboxAdminController {
|
||||
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
|
||||
.map(pair -> new MailboxView(user.getId(), user.getEmail(), user.getDisplayName(), user.isActive(),
|
||||
admins.isAdmin(user.getEmail()), user.isTrap(), user.isShared(), pair.getT2(), pair.getT1(), user.getQuotaBytes() == null ? 0 : user.getQuotaBytes(),
|
||||
user.getCreatedAt()));
|
||||
user.getCreatedAt(), admins.isAdmin(user.getEmail()) ? "ADMIN" : "USER", admins.isBuiltIn(user.getEmail())));
|
||||
}
|
||||
|
||||
private static ResponseStatusException bad(String message) {
|
||||
|
||||
@@ -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,45 @@ 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 administratorGivesAndTakesRoleAdminNotOwnNotBuiltInNotShared() {
|
||||
when(users.setAdmin(any(), any(Boolean.class))).thenAnswer(i -> {
|
||||
User u = i.getArgument(0);
|
||||
u.setAdmin(i.getArgument(1));
|
||||
return Mono.just(u);
|
||||
});
|
||||
var made = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block();
|
||||
assertThat(made.role()).isEqualTo("ADMIN");
|
||||
assertThat(admins.isAdmin("alice@jambocheck.com")).isTrue();
|
||||
// alice is ADMIN now — she manages roles too, but not the built-in one and not her own
|
||||
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block()))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
var back = controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("USER"))).block();
|
||||
assertThat(back.role()).isEqualTo("USER");
|
||||
assertThat(admins.isAdmin("alice@jambocheck.com")).isFalse();
|
||||
assertThat(status(() -> controller.role(as("alice@jambocheck.com"), 1L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
|
||||
alice.setShared(true);
|
||||
assertThat(status(() -> controller.role(as("admin@jambocheck.com"), 2L, Mono.just(new MailboxAdminController.RoleRequest("ADMIN"))).block()))
|
||||
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -15,7 +15,8 @@ export const routes: Routes = [
|
||||
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
|
||||
{ path: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
|
||||
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
|
||||
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) }
|
||||
{ path: 'settings', loadComponent: () => import('./pages/settings/settings.page').then(m => m.SettingsPage) },
|
||||
{ path: 'admin', loadComponent: () => import('./pages/admin/admin.page').then(m => m.AdminPage) }
|
||||
]
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
|
||||
@@ -55,6 +55,12 @@ export class ApiService {
|
||||
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||
}
|
||||
setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable<Mailbox> {
|
||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/role`, { role });
|
||||
}
|
||||
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'); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Folder, Profile } from './models';
|
||||
import { Folder, MailboxEntry, Profile } from './models';
|
||||
|
||||
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -9,6 +9,8 @@ export class MailStateService {
|
||||
|
||||
readonly folders = signal<Folder[]>([]);
|
||||
readonly profile = signal<Profile | null>(null);
|
||||
/** own, shared and attached mailboxes of the login — sidebar combobox, Settings → MY MAILBOXES */
|
||||
readonly boxes = signal<MailboxEntry[]>([]);
|
||||
readonly inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
|
||||
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
|
||||
|
||||
@@ -28,8 +30,13 @@ export class MailStateService {
|
||||
this.api.me().subscribe({ next: profile => this.profile.set(profile), error: () => {} });
|
||||
}
|
||||
|
||||
refreshBoxes(): void {
|
||||
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.folders.set([]);
|
||||
this.profile.set(null);
|
||||
this.boxes.set([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface Mailbox {
|
||||
usedBytes: number;
|
||||
quotaBytes: number;
|
||||
createdAt: string;
|
||||
role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists)
|
||||
builtIn: boolean; // administrator from the server configuration — stays ADMIN
|
||||
}
|
||||
|
||||
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { ListsSection } from '../settings/lists.section';
|
||||
import { MailboxesSection } from '../settings/mailboxes.section';
|
||||
import { UsersSection } from './users.section';
|
||||
|
||||
/** Admin panel (role ADMIN): users and their roles, mailboxes, mailing lists. */
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
imports: [MatIconModule, UsersSection, MailboxesSection, ListsSection],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (state.profile(); as p) {
|
||||
@if (p.admin) {
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
<app-users-section />
|
||||
<app-mailboxes-section />
|
||||
<app-lists-section />
|
||||
</div>
|
||||
} @else {
|
||||
<section class="glass-panel static p-6 text-slate-300 font-rajdhani" role="alert">
|
||||
<mat-icon class="align-middle text-pink-500">lock</mat-icon> Admin panel is only for users with the role ADMIN.
|
||||
</section>
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
export class AdminPage {
|
||||
readonly state = inject(MailStateService);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { Mailbox, errorText } from '../../core/models';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Admin panel → USERS: everyone with a login of their own (not shared mailboxes) and their role — ADMIN or USER. */
|
||||
@Component({
|
||||
selector: 'app-users-section',
|
||||
imports: [MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'block xl:col-span-2' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6" aria-labelledby="users-title">
|
||||
<h2 id="users-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-1 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">manage_accounts</mat-icon> USERS
|
||||
</h2>
|
||||
<p class="text-xs text-slate-400 font-mono mb-3">ADMIN — this Admin panel: users, mailboxes, lists. USER — own mail only.</p>
|
||||
@if (error()) { <p class="text-sm text-rose-300 mb-2" role="alert">{{ error() }}</p> }
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-[11px] font-mono text-slate-400 border-b border-cyan-500/20">
|
||||
<th class="py-2 pr-3 font-normal">User</th>
|
||||
<th class="py-2 pr-3 font-normal">Name</th>
|
||||
<th class="py-2 pr-3 font-normal">Role</th>
|
||||
<th class="py-2 pr-3 font-normal">Status</th>
|
||||
<th class="py-2 font-normal"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (u of users(); track u.id) {
|
||||
<tr class="border-b border-cyan-500/10">
|
||||
<td class="py-2 pr-3 font-mono" [class.text-slate-500]="!u.active">{{ u.email }}</td>
|
||||
<td class="py-2 pr-3 text-slate-300">{{ u.displayName }}</td>
|
||||
<td class="py-2 pr-3">
|
||||
<span class="text-[11px] font-mono px-1.5 rounded border"
|
||||
[class.role-admin]="u.role === 'ADMIN'" [class.border-slate-500]="u.role !== 'ADMIN'"
|
||||
[class.text-slate-300]="u.role !== 'ADMIN'">{{ u.role }}</span>
|
||||
@if (u.builtIn) { <span class="text-[10px] font-mono text-slate-500 ml-1" title="From the server configuration">built-in</span> }
|
||||
</td>
|
||||
<td class="py-2 pr-3 text-xs font-mono" [class.text-emerald-300]="u.active" [class.text-slate-500]="!u.active">{{ u.active ? 'active' : 'off' }}</td>
|
||||
<td class="py-2 text-right whitespace-nowrap">
|
||||
@if (u.email !== me() && !(u.builtIn && u.role === 'ADMIN')) {
|
||||
<button type="button" class="ghost-btn !py-1 text-xs" (click)="toggle(u)"
|
||||
[attr.aria-label]="(u.role === 'ADMIN' ? 'Make USER: ' : 'Make ADMIN: ') + u.email">
|
||||
<mat-icon>{{ u.role === 'ADMIN' ? 'person' : 'admin_panel_settings' }}</mat-icon>
|
||||
{{ u.role === 'ADMIN' ? 'MAKE USER' : 'MAKE ADMIN' }}</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="5" class="py-3 text-slate-500 font-mono text-xs">No users</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class UsersSection {
|
||||
private api = inject(ApiService);
|
||||
private state = inject(MailStateService);
|
||||
private dialogs = inject(DialogService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
private readonly all = signal<Mailbox[]>([]);
|
||||
readonly users = computed(() => this.all().filter(m => !m.shared));
|
||||
readonly me = computed(() => this.state.profile()?.email ?? '');
|
||||
readonly error = signal('');
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.api.mailboxes().subscribe({ next: list => this.all.set(list), error: e => this.error.set(errorText(e)) });
|
||||
}
|
||||
|
||||
toggle(u: Mailbox): void {
|
||||
const role = u.role === 'ADMIN' ? 'USER' : 'ADMIN';
|
||||
const text = role === 'ADMIN'
|
||||
? `${u.email} will manage users, mailboxes and lists (Admin panel).`
|
||||
: `${u.email} keeps only own mail, no Admin panel.`;
|
||||
this.dialogs.confirm(`Role ${role} for ${u.email}?`, text, 'Change role', role === 'USER').subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.setMailboxRole(u.id, role).subscribe({
|
||||
next: () => { this.toasts.ok(u.email + ' is ' + role + ' now'); this.load(); },
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,6 @@ import { AuthService } from '../../core/auth.service';
|
||||
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
|
||||
<span class="font-mono text-sm" [class.text-cyan-300]="m.active" [class.text-slate-500]="!m.active" [class.line-through]="!m.active">{{ m.email }}</span>
|
||||
<span class="text-sm text-slate-400">{{ m.displayName }}</span>
|
||||
@if (m.admin) { <span class="text-[10px] font-mono px-1.5 rounded border border-pink-500/40 text-pink-300">ADMIN</span> }
|
||||
@if (m.shared) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/50 text-cyan-300"
|
||||
[title]="'Members: ' + (m.members.join(', ') || 'none')">SHARED · {{ m.members.length }}</span> }
|
||||
@if (!m.shared && m.members.length) { <span class="text-[10px] font-mono px-1.5 rounded border border-cyan-400/30 text-cyan-300/80"
|
||||
@@ -91,6 +90,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 +229,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'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { MailStateService } from '../../core/mail-state.service';
|
||||
import { MailboxEntry, errorText } from '../../core/models';
|
||||
import { DialogService } from '../../ui/dialog.service';
|
||||
import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
/** Settings → MY MAILBOXES (everyone): mailboxes in the sidebar combobox — add one by its password, remove an added one. */
|
||||
@Component({
|
||||
selector: 'app-my-mailboxes-section',
|
||||
imports: [FormsModule, MatIconModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: 'block' },
|
||||
template: `
|
||||
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="my-boxes-title">
|
||||
<h2 id="my-boxes-title" class="text-lg font-orbitron font-bold text-cyan-300 flex items-center gap-2 mb-4 border-b border-cyan-500/20 pb-2">
|
||||
<mat-icon class="text-pink-500">alternate_email</mat-icon> MY MAILBOXES
|
||||
</h2>
|
||||
<ul class="space-y-1 mb-4" aria-label="Your mailboxes">
|
||||
@for (b of state.boxes(); track b.email) {
|
||||
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15">
|
||||
<mat-icon class="!text-lg text-slate-400 shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
|
||||
<span class="flex-grow truncate font-mono text-sm text-slate-200">{{ b.email }}</span>
|
||||
<span class="text-[11px] font-mono text-slate-500">{{ b.kind === 'OWN' ? 'own' : b.shared ? 'shared' : 'added' }}</span>
|
||||
@if (b.kind !== 'OWN') {
|
||||
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="leave(b)"
|
||||
[attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'" title="Remove from my mailboxes">
|
||||
<mat-icon>close</mat-icon></button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<form class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-3 items-end" (ngSubmit)="attach()" aria-label="Add a mailbox">
|
||||
<div><label class="synth-label" for="att-email">Mailbox</label>
|
||||
<input id="att-email" name="attEmail" class="synth-input" [(ngModel)]="email" autocomplete="off" placeholder="name@domain"></div>
|
||||
<div><label class="synth-label" for="att-pw">Its password</label>
|
||||
<input id="att-pw" name="attPw" type="password" class="synth-input" [(ngModel)]="password" autocomplete="off"></div>
|
||||
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
|
||||
</form>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class MyMailboxesSection {
|
||||
readonly state = inject(MailStateService);
|
||||
private api = inject(ApiService);
|
||||
private auth = inject(AuthService);
|
||||
private dialogs = inject(DialogService);
|
||||
private toasts = inject(ToastService);
|
||||
|
||||
email = '';
|
||||
password = '';
|
||||
|
||||
constructor() {
|
||||
this.state.refreshBoxes();
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
const email = this.email.trim().toLowerCase();
|
||||
if (!email || !this.password) { this.toasts.error('Mailbox and its password'); return; }
|
||||
this.api.attachMailbox(email, this.password).subscribe({
|
||||
next: b => {
|
||||
this.email = this.password = '';
|
||||
this.toasts.ok(b.email + ' added — pick it in the MAILBOX list on the left');
|
||||
this.state.refreshBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
leave(b: MailboxEntry): void {
|
||||
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
|
||||
? 'It disappears from your list. An administrator can give it back.'
|
||||
: 'It disappears from your list. Add it again with its password.', 'Remove', true).subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.leaveMailbox(b.email).subscribe({
|
||||
next: () => {
|
||||
this.toasts.ok(b.email + ' removed');
|
||||
if (b.email === this.auth.email()) {
|
||||
// removed the one we are in — back to the own mailbox
|
||||
this.api.mailboxSession(this.state.boxes().find(x => x.kind === 'OWN')?.email ?? '').subscribe({
|
||||
next: session => { this.state.reset(); this.auth.switchTo(session); location.assign('/'); },
|
||||
error: () => location.assign('/')
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.state.refreshBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,12 @@ import { MailStateService } from '../../core/mail-state.service';
|
||||
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';
|
||||
import { MyMailboxesSection } from './my-mailboxes.section';
|
||||
|
||||
/** Profile name, password, storage, look. */
|
||||
/** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
|
||||
imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||
@@ -93,7 +92,7 @@ import { ListsSection } from './lists.section';
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@for (t of themes; track t.value) {
|
||||
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === t.value"
|
||||
[class.!border-pink-500]="theme.theme() === t.value" [class.!text-pink-300]="theme.theme() === t.value"
|
||||
[class.nav-active]="theme.theme() === t.value"
|
||||
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
|
||||
}
|
||||
</div>
|
||||
@@ -112,10 +111,8 @@ import { ListsSection } from './lists.section';
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (state.profile()?.admin) {
|
||||
<app-mailboxes-section />
|
||||
<app-lists-section />
|
||||
}
|
||||
<app-my-mailboxes-section />
|
||||
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
@@ -81,43 +81,15 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<div class="flex-grow max-w-[1500px] w-full mx-auto p-3 md:p-5 grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-5">
|
||||
<nav class="glass-panel static p-4 md:pt-6 h-fit lg:sticky lg:top-20 lg:block" [class.hidden]="!menuOpen()"
|
||||
aria-label="Mail folders">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
|
||||
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
|
||||
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
|
||||
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
|
||||
</div>
|
||||
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
|
||||
@for (b of boxes(); track b.email) {
|
||||
<li class="flex items-center gap-1">
|
||||
<button type="button" (click)="switchTo(b)" [attr.aria-current]="b.email === auth.email() ? 'true' : null"
|
||||
class="flex-grow flex items-center gap-2 px-3 py-1.5 rounded-lg border text-left font-rajdhani font-semibold min-w-0"
|
||||
[class.border-pink-500/40]="b.email === auth.email()" [class.bg-pink-500/15]="b.email === auth.email()"
|
||||
[class.text-pink-200]="b.email === auth.email()" [class.border-transparent]="b.email !== auth.email()"
|
||||
[class.text-slate-300]="b.email !== auth.email()"
|
||||
[matTooltip]="b.email + (b.shared ? ' — shared' : b.kind === 'ATTACHED' ? ' — attached' : '')">
|
||||
<mat-icon class="!text-lg shrink-0">{{ b.shared ? 'groups' : b.kind === 'OWN' ? 'person' : 'link' }}</mat-icon>
|
||||
<span class="flex-grow truncate text-sm">{{ b.email }}</span>
|
||||
@if (b.unread) {
|
||||
<span class="text-[11px] font-mono px-2 rounded-full bg-pink-500/25 text-pink-300" [attr.aria-label]="b.unread + ' unread'">{{ b.unread }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (b.kind !== 'OWN') {
|
||||
<button type="button" class="text-slate-500 hover:text-pink-400" [attr.aria-label]="'Remove ' + b.email + ' from my mailboxes'"
|
||||
matTooltip="Remove from my mailboxes" (click)="leave(b)"><mat-icon class="!text-base">close</mat-icon></button>
|
||||
}
|
||||
</li>
|
||||
<label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
|
||||
<select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
|
||||
(ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
|
||||
@for (b of state.boxes(); track b.email) {
|
||||
<option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
|
||||
} @empty {
|
||||
<option [value]="auth.email()">{{ auth.email() }}</option>
|
||||
}
|
||||
</ul>
|
||||
@if (attachOpen()) {
|
||||
<form class="space-y-2 mb-4 p-2 rounded-lg border border-cyan-500/20" (ngSubmit)="attach()" aria-label="Add a mailbox">
|
||||
<label class="synth-label" for="att-email">Mailbox</label>
|
||||
<input id="att-email" name="attEmail" class="synth-input !py-1.5" [(ngModel)]="attachEmail" autocomplete="off" placeholder="name@domain">
|
||||
<label class="synth-label" for="att-pw">Its password</label>
|
||||
<input id="att-pw" name="attPw" type="password" class="synth-input !py-1.5" [(ngModel)]="attachPassword" autocomplete="off">
|
||||
<button type="submit" class="cyber-btn w-full py-1.5 rounded-lg text-xs font-bold"><mat-icon>link</mat-icon> ADD</button>
|
||||
</form>
|
||||
}
|
||||
</select>
|
||||
|
||||
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
|
||||
<mat-icon>edit</mat-icon> COMPOSE
|
||||
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<ul class="space-y-1">
|
||||
@for (folder of systemFolders(); track folder.id) {
|
||||
<li>
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
|
||||
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
|
||||
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
|
||||
<ul class="space-y-1">
|
||||
@for (folder of ownFolders(); track folder.id) {
|
||||
<li class="flex items-center gap-1">
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="!bg-pink-500/20 !text-pink-300 !border-pink-500/40"
|
||||
<a [routerLink]="['/folder', folder.id]" routerLinkActive="nav-active"
|
||||
class="flex-grow flex items-center gap-3 px-3 py-1.5 rounded-lg border border-transparent text-slate-300 hover:bg-cyan-500/10 hover:text-cyan-300 font-rajdhani font-semibold min-w-0"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">folder</mat-icon>
|
||||
@@ -166,12 +138,19 @@ import { ToastService } from '../../ui/toast.service';
|
||||
|
||||
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
|
||||
@for (link of links; track link.path) {
|
||||
<a [routerLink]="link.path" routerLinkActive="!text-pink-300"
|
||||
<a [routerLink]="link.path" routerLinkActive="nav-active-text"
|
||||
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
|
||||
</a>
|
||||
}
|
||||
@if (state.profile()?.admin) {
|
||||
<a routerLink="/admin" routerLinkActive="nav-active-text"
|
||||
class="flex items-center gap-3 px-3 py-1.5 rounded-lg text-slate-400 hover:text-cyan-300 font-rajdhani font-semibold"
|
||||
(click)="menuOpen.set(false)">
|
||||
<mat-icon class="!text-xl">admin_panel_settings</mat-icon>Admin panel
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (state.profile(); as p) {
|
||||
@@ -203,17 +182,12 @@ export class ShellComponent {
|
||||
|
||||
readonly themes = THEMES;
|
||||
readonly menuOpen = signal(false);
|
||||
/** own, shared and attached mailboxes of the login (shown when there is more than the own one, or to add one) */
|
||||
readonly boxes = signal<MailboxEntry[]>([]);
|
||||
readonly attachOpen = signal(false);
|
||||
attachEmail = '';
|
||||
attachPassword = '';
|
||||
query = '';
|
||||
|
||||
readonly links = [
|
||||
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
|
||||
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
|
||||
{ path: '/settings', label: 'Settings', icon: 'settings' }
|
||||
{ path: '/settings', label: 'User panel', icon: 'person' }
|
||||
];
|
||||
|
||||
readonly folderActions: RowAction[] = [
|
||||
@@ -238,8 +212,8 @@ export class ShellComponent {
|
||||
constructor() {
|
||||
this.state.refreshFolders();
|
||||
this.state.refreshProfile();
|
||||
this.loadBoxes();
|
||||
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
|
||||
this.state.refreshBoxes();
|
||||
const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 30000);
|
||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||
const q = this.router.parseUrl(this.router.url).queryParams['q'];
|
||||
@@ -300,10 +274,6 @@ export class ShellComponent {
|
||||
}
|
||||
}
|
||||
|
||||
loadBoxes(): void {
|
||||
this.api.myMailboxes().subscribe({ next: list => this.boxes.set(list), error: () => {} });
|
||||
}
|
||||
|
||||
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
|
||||
switchTo(b: MailboxEntry): void {
|
||||
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
||||
@@ -313,37 +283,9 @@ export class ShellComponent {
|
||||
});
|
||||
}
|
||||
|
||||
attach(): void {
|
||||
const email = this.attachEmail.trim().toLowerCase();
|
||||
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
|
||||
this.api.attachMailbox(email, this.attachPassword).subscribe({
|
||||
next: b => {
|
||||
this.attachEmail = this.attachPassword = '';
|
||||
this.attachOpen.set(false);
|
||||
this.toasts.ok(b.email + ' added to your mailboxes');
|
||||
this.loadBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
}
|
||||
|
||||
leave(b: MailboxEntry): void {
|
||||
this.dialogs.confirm(`Remove ${b.email}?`, b.shared
|
||||
? 'It disappears from your menu. An administrator can give it back.'
|
||||
: 'It disappears from your menu. Add it again with its password.', 'Remove', true).subscribe(ok => {
|
||||
if (ok === null) return;
|
||||
this.api.leaveMailbox(b.email).subscribe({
|
||||
next: () => {
|
||||
this.toasts.ok(b.email + ' removed');
|
||||
if (b.email === this.auth.email()) {
|
||||
const own = this.boxes().find(x => x.kind === 'OWN');
|
||||
if (own) { this.switchTo(own); return; }
|
||||
}
|
||||
this.loadBoxes();
|
||||
},
|
||||
error: e => this.toasts.error(errorText(e))
|
||||
});
|
||||
});
|
||||
pick(email: string): void {
|
||||
const b = this.state.boxes().find(x => x.email === email);
|
||||
if (b) this.switchTo(b);
|
||||
}
|
||||
|
||||
backToAdmin(): void {
|
||||
|
||||
@@ -300,6 +300,20 @@ 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; }
|
||||
/* Selected item of the sidebar (folder, link) — in the colour of the theme, not a fixed pink */
|
||||
.nav-active {
|
||||
background-color: color-mix(in srgb, var(--primary-color) 18%, transparent) !important;
|
||||
border-color: color-mix(in srgb, var(--primary-color) 45%, transparent) !important;
|
||||
color: var(--primary-color) !important;
|
||||
}
|
||||
.nav-active-text { color: var(--primary-color) !important; }
|
||||
.role-admin { color: var(--primary-color); border-color: color-mix(in srgb, var(--primary-color) 50%, transparent); }
|
||||
|
||||
/* 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 {
|
||||
|
||||
Reference in New Issue
Block a user