Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e12d33f3 | ||
|
|
910a028612 | ||
|
|
cf87b60b38 | ||
|
|
402d2c403b | ||
|
|
8eb69b32e5 | ||
|
|
3d9fc77201 |
@@ -0,0 +1,43 @@
|
|||||||
|
package com.mailserver.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Sign in with Jambotron»: JAMBOTRON_URL (https://jambotron.com) + SERVICE_TOKEN (the same token Jambotron uses for
|
||||||
|
* the service API — here it proves to Jambotron that the code is exchanged by this mail server). Both set — people log
|
||||||
|
* in through Jambotron, web password login only for the built-in administrator.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JambotronSso {
|
||||||
|
|
||||||
|
private final String url;
|
||||||
|
private final String token;
|
||||||
|
private final String publicUrl;
|
||||||
|
|
||||||
|
public JambotronSso(@Value("${mailserver.jambotron.url:}") String url,
|
||||||
|
@Value("${mailserver.service-token:}") String token,
|
||||||
|
@Value("${mailserver.public-url:}") String publicUrl,
|
||||||
|
@Value("${mailserver.smtp.hostname:localhost}") String hostname) {
|
||||||
|
this.url = url.isBlank() ? null : url.trim().replaceAll("/+$", "");
|
||||||
|
this.token = token.isBlank() ? null : token.trim();
|
||||||
|
this.publicUrl = publicUrl.isBlank() ? "https://" + hostname : publicUrl.trim().replaceAll("/+$", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean enabled() {
|
||||||
|
return url != null && token != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String url() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String token() {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where Jambotron sends the browser back: https://mail.jambocheck.com/api/oauth/callback */
|
||||||
|
public String callback() {
|
||||||
|
return publicUrl + "/api/oauth/callback";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,17 @@ public class MailAdmins {
|
|||||||
return admins.contains(key) || granted.contains(key);
|
return admins.contains(key) || granted.contains(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Given / taken by Jambotron (users.is_admin) — loaded at start and kept here on every change. */
|
/** Built-in administrators (lower case) — owners of system mailboxes. */
|
||||||
|
public Set<String> builtIn() {
|
||||||
|
return admins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) {
|
public void grant(String email, boolean admin) {
|
||||||
String key = email.toLowerCase(Locale.ROOT);
|
String key = email.toLowerCase(Locale.ROOT);
|
||||||
if (admin) {
|
if (admin) {
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
/** At start: mailboxes Jambotron made administrators (users.is_admin) → {@link MailAdmins}. */
|
/** At start: role ADMIN (users.is_admin) → {@link MailAdmins}; the built-in administrator gets the role in the database too. */
|
||||||
@Component
|
@Component
|
||||||
public class MailAdminsLoader {
|
public class MailAdminsLoader {
|
||||||
|
|
||||||
@@ -23,10 +24,13 @@ public class MailAdminsLoader {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void load() {
|
public void load() {
|
||||||
users.findAll().filter(user -> user.isAdmin())
|
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))
|
.doOnNext(user -> admins.grant(user.getEmail(), true))
|
||||||
.count()
|
.count()
|
||||||
.subscribe(n -> log.info("Mail administrators from Jambotron: {}", n),
|
.subscribe(n -> log.info("Mail administrators (role ADMIN): {}", n),
|
||||||
e -> log.error("Mail administrators from Jambotron not loaded: {}", e.getMessage()));
|
e -> log.error("Mail administrators not loaded: {}", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class SecurityConfig {
|
|||||||
.authorizeExchange(exchange -> exchange
|
.authorizeExchange(exchange -> exchange
|
||||||
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
.pathMatchers("/api/login", "/api/register", "/api/config", "/actuator/health").permitAll()
|
||||||
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
|
// service API for Jambotron: its own token, checked by ServiceController (not a mailbox login)
|
||||||
.pathMatchers("/api/service/**").permitAll()
|
.pathMatchers("/api/service/**", "/api/oauth/start", "/api/oauth/callback").permitAll()
|
||||||
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
.pathMatchers("/api/**", "/actuator/**").authenticated()
|
||||||
// the web client (static files, SPA routes)
|
// the web client (static files, SPA routes)
|
||||||
.anyExchange().permitAll()
|
.anyExchange().permitAll()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.mailserver.service.AppPasswords;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
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;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
/** User panel → APP PASSWORDS: a password per mail program (Thunderbird, phone), SMTP / IMAP only. */
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/app-passwords")
|
||||||
|
public class AppPasswordsController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AppPasswordsController.class);
|
||||||
|
|
||||||
|
public record CreateRequest(@Size(max = 100) String name) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param password shown once */
|
||||||
|
public record Created(AppPasswords.AppPassword appPassword, String password) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final AppPasswords passwords;
|
||||||
|
private final UserService users;
|
||||||
|
|
||||||
|
public AppPasswordsController(AppPasswords passwords, UserService users) {
|
||||||
|
this.passwords = passwords;
|
||||||
|
this.users = users;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Flux<AppPasswords.AppPassword> list(Mono<Authentication> auth) {
|
||||||
|
return me(auth).flatMapMany(id -> passwords.list(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public Mono<Created> create(Mono<Authentication> auth, @RequestBody Mono<CreateRequest> body) {
|
||||||
|
return me(auth).zipWith(body).flatMap(pair -> {
|
||||||
|
String name = pair.getT2().name() == null || pair.getT2().name().isBlank() ? "Mail program" : pair.getT2().name().trim();
|
||||||
|
return passwords.create(pair.getT1(), name)
|
||||||
|
.flatMap(password -> passwords.list(pair.getT1()).last()
|
||||||
|
.doOnNext(p -> log.info("App password '{}' created for user {}", name, pair.getT1()))
|
||||||
|
.map(p -> new Created(p, password)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public Mono<Void> delete(Mono<Authentication> auth, @PathVariable Long id) {
|
||||||
|
return me(auth).flatMap(userId -> passwords.delete(userId, id))
|
||||||
|
.flatMap(deleted -> deleted ? Mono.<Void>empty()
|
||||||
|
: Mono.error(new ResponseStatusException(HttpStatus.NOT_FOUND, "No such app password")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Long> me(Mono<Authentication> auth) {
|
||||||
|
return auth.map(Authentication::getName).flatMap(users::findByEmail).map(u -> u.getId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,10 +57,12 @@ public class AuthenticationController {
|
|||||||
private final boolean registrationEnabled;
|
private final boolean registrationEnabled;
|
||||||
private final LoginGuard guard;
|
private final LoginGuard guard;
|
||||||
private final MailAdmins admins;
|
private final MailAdmins admins;
|
||||||
|
private final com.mailserver.config.JambotronSso sso;
|
||||||
|
|
||||||
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
public AuthenticationController(UserService userService, MailService mailService, JwtUtils jwtUtils, MailDomains domains,
|
||||||
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
@Value("${mailserver.registration.enabled:false}") boolean registrationEnabled,
|
||||||
LoginGuard guard, MailAdmins admins) {
|
LoginGuard guard, MailAdmins admins, com.mailserver.config.JambotronSso sso) {
|
||||||
|
this.sso = sso;
|
||||||
this.guard = guard;
|
this.guard = guard;
|
||||||
this.admins = admins;
|
this.admins = admins;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -73,7 +75,8 @@ public class AuthenticationController {
|
|||||||
/** Public: is registration open, and in which domains mailboxes can be created. */
|
/** Public: is registration open, and in which domains mailboxes can be created. */
|
||||||
@GetMapping("/config")
|
@GetMapping("/config")
|
||||||
public Map<String, Object> config() {
|
public Map<String, Object> config() {
|
||||||
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList());
|
return Map.of("registrationEnabled", registrationEnabled, "domains", domains.domains().stream().sorted().toList(),
|
||||||
|
"ssoEnabled", sso.enabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@@ -106,6 +109,16 @@ public class AuthenticationController {
|
|||||||
if (!user.isActive()) {
|
if (!user.isActive()) {
|
||||||
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
return Mono.just(ResponseEntity.badRequest().body(Map.of("error", "User is deactivated")));
|
||||||
}
|
}
|
||||||
|
// With «Sign in with Jambotron» the web password is only for the built-in administrator
|
||||||
|
// (Jambotron down); the others' passwords are for mail programs (SMTP / IMAP)
|
||||||
|
if (sso.enabled() && !admins.isBuiltIn(user.getEmail())) {
|
||||||
|
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
|
||||||
|
"Sign in with Jambotron — the password is only for mail programs")));
|
||||||
|
}
|
||||||
|
if (user.isSystem()) {
|
||||||
|
return Mono.just(ResponseEntity.badRequest().body(Map.of("error",
|
||||||
|
"A system mailbox has no web login: its owners open it from their mailbox")));
|
||||||
|
}
|
||||||
return Mono.just(ResponseEntity.ok(token(user)));
|
return Mono.just(ResponseEntity.ok(token(user)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,8 +45,16 @@ public class MailboxAdminController {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
private static final Logger log = LoggerFactory.getLogger(MailboxAdminController.class);
|
||||||
private static final int MIN_PASSWORD = MailDirectory.MIN_PASSWORD;
|
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,
|
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, boolean system) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SystemRequest(boolean system) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RoleRequest(String role) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param password empty — the server generates one and returns it once */
|
/** @param password empty — the server generates one and returns it once */
|
||||||
@@ -171,6 +179,49 @@ public class MailboxAdminController {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() || user.isSystem())) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System mailbox (no-reply@, monitor@, staging@): no web login, not in USERS; the built-in administrators
|
||||||
|
* open it from their mailbox; programs keep its password. Off — an ordinary mailbox again.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{id}/system")
|
||||||
|
public Mono<MailboxView> system(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<SystemRequest> body) {
|
||||||
|
return admin(auth).flatMap(me -> body.flatMap(request -> mailbox(id).flatMap(user -> {
|
||||||
|
if (request.system() && (user.isShared() || admins.isAdmin(user.getEmail()))) {
|
||||||
|
return Mono.error(bad("A shared mailbox or an ADMIN can not be a system mailbox"));
|
||||||
|
}
|
||||||
|
log.info("Mailbox {} system {} by administrator {}", user.getEmail(), request.system() ? "ON" : "OFF", me);
|
||||||
|
return users.setSystem(user, request.system())
|
||||||
|
.flatMap(u -> !request.system() ? Mono.just(u)
|
||||||
|
: Flux.fromIterable(admins.builtIn()).concatMap(users::findOptional)
|
||||||
|
.concatMap(owner -> access.grant(u.getId(), owner.getId(), MailboxAccess.MEMBER))
|
||||||
|
.then(Mono.just(u)));
|
||||||
|
}))).flatMap(this::view);
|
||||||
|
}
|
||||||
|
|
||||||
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
|
/** Trap: what the mailbox sends is stored in its own INBOX, nothing leaves the server (staging@). */
|
||||||
@PutMapping("/{id}/trap")
|
@PutMapping("/{id}/trap")
|
||||||
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
|
public Mono<MailboxView> trap(Mono<Authentication> auth, @PathVariable Long id, @RequestBody Mono<TrapRequest> body) {
|
||||||
@@ -237,7 +288,7 @@ public class MailboxAdminController {
|
|||||||
return mail.usedBytes(user.getId()).defaultIfEmpty(0L).zipWith(access.members(user.getId()).collectList())
|
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(),
|
.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(),
|
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()), user.isSystem()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseStatusException bad(String message) {
|
private static ResponseStatusException bad(String message) {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.mailserver.config.JambotronSso;
|
||||||
|
import com.mailserver.config.MailAdmins;
|
||||||
|
import com.mailserver.domain.User;
|
||||||
|
import com.mailserver.service.UserService;
|
||||||
|
import com.mailserver.util.JwtUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Sign in with Jambotron» — OAuth 2.0 authorization code + PKCE (S256):
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code /api/oauth/start} → browser to {@code JAMBOTRON_URL/oauth/authorize?client_id=mail&…}</li>
|
||||||
|
* <li>Jambotron logs the person in (Google / GitHub / password), only staff get a one-time code</li>
|
||||||
|
* <li>{@code /api/oauth/callback?code&state} → code exchanged server to server at {@code /api/oauth/token}
|
||||||
|
* (Bearer SERVICE_TOKEN + code_verifier) → who it is, roles, their work mailbox</li>
|
||||||
|
* <li>session of that mailbox → {@code /sso#token=…}; ADMIN of Jambotron gets role ADMIN here</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/oauth")
|
||||||
|
public class SsoController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SsoController.class);
|
||||||
|
private static final Duration STATE_TTL = Duration.ofMinutes(10);
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
/** What Jambotron says about the person (its /api/oauth/token). */
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Identity(String sub, String username, String email, List<String> roles, String mailbox) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Pending(String verifier, Instant until) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Map<String, Pending> pending = new ConcurrentHashMap<>();
|
||||||
|
private final JambotronSso sso;
|
||||||
|
private final UserService users;
|
||||||
|
private final MailAdmins admins;
|
||||||
|
private final JwtUtils jwt;
|
||||||
|
private final WebClient http;
|
||||||
|
|
||||||
|
public SsoController(JambotronSso sso, UserService users, MailAdmins admins, JwtUtils jwt, WebClient.Builder http) {
|
||||||
|
this.sso = sso;
|
||||||
|
this.users = users;
|
||||||
|
this.admins = admins;
|
||||||
|
this.jwt = jwt;
|
||||||
|
this.http = http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/start")
|
||||||
|
public ResponseEntity<Void> start() {
|
||||||
|
if (!sso.enabled()) {
|
||||||
|
return back("/login?sso_error=" + enc("Sign in with Jambotron is not set up on this server"));
|
||||||
|
}
|
||||||
|
Instant now = Instant.now();
|
||||||
|
pending.values().removeIf(p -> p.until().isBefore(now));
|
||||||
|
String state = random(24);
|
||||||
|
String verifier = random(48);
|
||||||
|
pending.put(state, new Pending(verifier, now.plus(STATE_TTL)));
|
||||||
|
return back(sso.url() + "/oauth/authorize?response_type=code&client_id=mail"
|
||||||
|
+ "&redirect_uri=" + enc(sso.callback()) + "&state=" + state
|
||||||
|
+ "&code_challenge=" + challenge(verifier) + "&code_challenge_method=S256");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/callback")
|
||||||
|
public Mono<ResponseEntity<Void>> callback(@RequestParam(required = false) String code,
|
||||||
|
@RequestParam(required = false) String state,
|
||||||
|
@RequestParam(required = false) String error) {
|
||||||
|
Pending p = state == null ? null : pending.remove(state);
|
||||||
|
if (error != null) {
|
||||||
|
return Mono.just(fail(error));
|
||||||
|
}
|
||||||
|
if (code == null || p == null || p.until().isBefore(Instant.now())) {
|
||||||
|
return Mono.just(fail("The sign-in took too long or was not started here — try again"));
|
||||||
|
}
|
||||||
|
return http.post().uri(sso.url() + "/api/oauth/token")
|
||||||
|
.header("Authorization", "Bearer " + sso.token())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.bodyValue(Map.of("grant_type", "authorization_code", "client_id", "mail", "code", code,
|
||||||
|
"code_verifier", p.verifier(), "redirect_uri", sso.callback()))
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Identity.class)
|
||||||
|
.timeout(Duration.ofSeconds(15))
|
||||||
|
.flatMap(this::session)
|
||||||
|
.onErrorResume(WebClientResponseException.class, e -> {
|
||||||
|
log.warn("Sign in with Jambotron: token exchange refused {} {}", e.getStatusCode(), e.getResponseBodyAsString());
|
||||||
|
return Mono.just(fail("Jambotron did not confirm the sign-in (" + e.getStatusCode().value() + ")"));
|
||||||
|
})
|
||||||
|
.onErrorResume(e -> !(e instanceof SsoRefused), e -> {
|
||||||
|
log.warn("Sign in with Jambotron failed: {}", e.toString());
|
||||||
|
return Mono.just(fail("Jambotron is not reachable — try again later"));
|
||||||
|
})
|
||||||
|
.onErrorResume(SsoRefused.class, e -> Mono.just(fail(e.getMessage())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ResponseEntity<Void>> session(Identity who) {
|
||||||
|
if (who.mailbox() == null || who.mailbox().isBlank()) {
|
||||||
|
return Mono.error(new SsoRefused("You have no work mailbox: mail is for Jambotron staff"));
|
||||||
|
}
|
||||||
|
boolean admin = who.roles() != null && who.roles().contains("ROLE_ADMIN");
|
||||||
|
return users.findOptional(who.mailbox())
|
||||||
|
.switchIfEmpty(Mono.error(new SsoRefused("Your mailbox " + who.mailbox() + " is not created yet — it appears within 10 minutes")))
|
||||||
|
.flatMap(user -> {
|
||||||
|
if (!user.isActive() || user.isShared() || user.isSystem()) {
|
||||||
|
return Mono.error(new SsoRefused("Mailbox " + who.mailbox() + " is switched off"));
|
||||||
|
}
|
||||||
|
return admin && !user.isAdmin() ? users.setAdmin(user, true) : Mono.just(user);
|
||||||
|
})
|
||||||
|
.map(user -> {
|
||||||
|
if (user.isAdmin()) {
|
||||||
|
admins.grant(user.getEmail(), true);
|
||||||
|
}
|
||||||
|
log.info("WEB: {} signed in with Jambotron as {} ({})", user.getEmail(), who.username(), who.email());
|
||||||
|
return back("/sso#token=" + enc(jwt.generateToken(user.getEmail())) + "&email=" + enc(user.getEmail()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class SsoRefused extends RuntimeException {
|
||||||
|
SsoRefused(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<Void> fail(String message) {
|
||||||
|
return back("/login?sso_error=" + enc(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResponseEntity<Void> back(String location) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(location)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String enc(String value) {
|
||||||
|
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String random(int bytes) {
|
||||||
|
byte[] b = new byte[bytes];
|
||||||
|
RANDOM.nextBytes(b);
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String challenge(String verifier) {
|
||||||
|
try {
|
||||||
|
byte[] hash = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ public class User {
|
|||||||
@Column("is_admin")
|
@Column("is_admin")
|
||||||
private boolean isAdmin = false;
|
private boolean isAdmin = false;
|
||||||
|
|
||||||
|
/** System mailbox (no-reply@ …): no web login, owners open it, programs use its passwords (V9). */
|
||||||
|
@Column("is_system")
|
||||||
|
private boolean isSystem = false;
|
||||||
|
|
||||||
/** Shared mailbox (V7): no login of its own, members open it. */
|
/** Shared mailbox (V7): no login of its own, members open it. */
|
||||||
@Column("is_shared")
|
@Column("is_shared")
|
||||||
private boolean isShared = false;
|
private boolean isShared = false;
|
||||||
@@ -70,6 +74,8 @@ public class User {
|
|||||||
public void setTrap(boolean trap) { isTrap = trap; }
|
public void setTrap(boolean trap) { isTrap = trap; }
|
||||||
public boolean isAdmin() { return isAdmin; }
|
public boolean isAdmin() { return isAdmin; }
|
||||||
public void setAdmin(boolean admin) { isAdmin = admin; }
|
public void setAdmin(boolean admin) { isAdmin = admin; }
|
||||||
|
public boolean isSystem() { return isSystem; }
|
||||||
|
public void setSystem(boolean system) { isSystem = system; }
|
||||||
|
|
||||||
public boolean isShared() { return isShared; }
|
public boolean isShared() { return isShared; }
|
||||||
public void setShared(boolean shared) { isShared = shared; }
|
public void setShared(boolean shared) { isShared = shared; }
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.mailserver.service;
|
||||||
|
|
||||||
|
import org.springframework.r2dbc.core.DatabaseClient;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** App passwords (V9): one per mail program, SMTP / IMAP only — never the web client. */
|
||||||
|
@Service
|
||||||
|
public class AppPasswords {
|
||||||
|
|
||||||
|
public record AppPassword(Long id, String name, LocalDateTime createdAt, LocalDateTime lastUsedAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DatabaseClient db;
|
||||||
|
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
|
public AppPasswords(DatabaseClient db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Flux<AppPassword> list(Long userId) {
|
||||||
|
return db.sql("SELECT id, name, created_at, last_used_at FROM app_passwords WHERE user_id = :u ORDER BY id")
|
||||||
|
.bind("u", userId)
|
||||||
|
.map(row -> new AppPassword(row.get("id", Long.class), row.get("name", String.class),
|
||||||
|
row.get("created_at", LocalDateTime.class), row.get("last_used_at", LocalDateTime.class)))
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return the password itself — shown once */
|
||||||
|
public Mono<String> create(Long userId, String name) {
|
||||||
|
String password = MailDirectory.randomPassword();
|
||||||
|
return db.sql("INSERT INTO app_passwords (user_id, name, hash) VALUES (:u, :n, :h)")
|
||||||
|
.bind("u", userId).bind("n", name).bind("h", encoder.encode(password))
|
||||||
|
.then().thenReturn(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Boolean> delete(Long userId, Long id) {
|
||||||
|
return db.sql("DELETE FROM app_passwords WHERE id = :id AND user_id = :u").bind("id", id).bind("u", userId)
|
||||||
|
.fetch().rowsUpdated().map(n -> n > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMTP / IMAP: does the password match one of the app passwords of this login (and note the use). */
|
||||||
|
public boolean matches(Long userId, String password) {
|
||||||
|
Long hit = db.sql("SELECT id, hash FROM app_passwords WHERE user_id = :u").bind("u", userId)
|
||||||
|
.map(row -> new Object[]{row.get("id", Long.class), row.get("hash", String.class)})
|
||||||
|
.all()
|
||||||
|
.filter(r -> encoder.matches(password, (String) r[1]))
|
||||||
|
.map(r -> (Long) r[0])
|
||||||
|
.next().block();
|
||||||
|
if (hit == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
db.sql("UPDATE app_passwords SET last_used_at = NOW() WHERE id = :id").bind("id", hit).then().subscribe();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,15 +30,18 @@ public class MailboxAccess {
|
|||||||
private final UserRepository users;
|
private final UserRepository users;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService) {
|
private final AppPasswords appPasswords;
|
||||||
|
|
||||||
|
public MailboxAccess(DatabaseClient db, UserRepository users, UserService userService, AppPasswords appPasswords) {
|
||||||
|
this.appPasswords = appPasswords;
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.users = users;
|
this.users = users;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password → the mailbox the
|
* Login for SMTP / IMAP: {@code login} or {@code mailbox*login} + the login's password or one of its app
|
||||||
* session works with, or null. A shared mailbox never logs in by itself.
|
* passwords (V9) → the mailbox the session works with, or null. A shared mailbox never logs in by itself.
|
||||||
*/
|
*/
|
||||||
public User login(String username, String password) {
|
public User login(String username, String password) {
|
||||||
String name = username.trim().toLowerCase(Locale.ROOT);
|
String name = username.trim().toLowerCase(Locale.ROOT);
|
||||||
@@ -46,7 +49,8 @@ public class MailboxAccess {
|
|||||||
String mailbox = star < 0 ? name : name.substring(0, star);
|
String mailbox = star < 0 ? name : name.substring(0, star);
|
||||||
String login = star < 0 ? name : name.substring(star + 1);
|
String login = star < 0 ? name : name.substring(star + 1);
|
||||||
User user = users.findByEmail(login).block();
|
User user = users.findByEmail(login).block();
|
||||||
if (user == null || !user.isActive() || user.isShared() || !userService.checkPassword(user, password)) {
|
if (user == null || !user.isActive() || user.isShared()
|
||||||
|
|| !(userService.checkPassword(user, password) || appPasswords.matches(user.getId(), password))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (mailbox.equals(login)) {
|
if (mailbox.equals(login)) {
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ public class UserService {
|
|||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Mono<User> setSystem(User user, boolean system) {
|
||||||
|
user.setSystem(system);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
public Mono<User> setTrap(User user, boolean trap) {
|
public Mono<User> setTrap(User user, boolean trap) {
|
||||||
user.setTrap(trap);
|
user.setTrap(trap);
|
||||||
return userRepository.save(user);
|
return userRepository.save(user);
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ mailserver.passwords-file=${MAIL_PASSWORDS_FILE:}
|
|||||||
# Service API for Jambotron (/api/service/**, staff mailboxes and lists); empty — the API is off.
|
# 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).
|
# In the compose files: generated by secrets-init into the secrets volume (file SERVICE_TOKEN).
|
||||||
mailserver.service-token=${SERVICE_TOKEN:}
|
mailserver.service-token=${SERVICE_TOKEN:}
|
||||||
|
# Sign in with Jambotron (OAuth 2.0 code + PKCE): https://jambotron.com; empty — password login for everyone
|
||||||
|
mailserver.jambotron.url=${JAMBOTRON_URL:}
|
||||||
|
# address of this web client for the OAuth callback; empty — https://MAIL_HOSTNAME
|
||||||
|
mailserver.public-url=${MAIL_PUBLIC_URL:}
|
||||||
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
mailserver.cors.origins=${MAIL_CORS_ORIGINS:}
|
||||||
|
|
||||||
# Attachments
|
# Attachments
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Sign in with Jambotron (OAuth 2.0 code + PKCE): people log in through Jambotron; passwords stay for mail programs.
|
||||||
|
-- App passwords: one per program (Thunderbird, phone, Kuma …), SMTP / IMAP only, shown once, revoked one by one.
|
||||||
|
CREATE TABLE app_passwords (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
hash VARCHAR(100) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX app_passwords_user ON app_passwords(user_id);
|
||||||
|
|
||||||
|
-- System mailbox (no-reply@, monitor@, staging@): no person behind it, no web login, its owners (built-in
|
||||||
|
-- administrators) open it from their own mailbox; programs use its password / app passwords.
|
||||||
|
ALTER TABLE users ADD COLUMN is_system BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -33,8 +33,9 @@ class AuthenticationControllerTest {
|
|||||||
private final JwtUtils jwt = mock(JwtUtils.class);
|
private final JwtUtils jwt = mock(JwtUtils.class);
|
||||||
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
private final LoginGuard guard = new LoginGuard(new LoginGuard.Settings(true, 3, 100, Duration.ofMinutes(15),
|
||||||
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
Duration.ofMinutes(15), Duration.ofHours(24), Duration.ofDays(30), Set.of(), Duration.ZERO));
|
||||||
|
private final com.mailserver.config.MailAdmins admins = new com.mailserver.config.MailAdmins("admin@x.test", "", new MailDomains("x.test", ""));
|
||||||
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
private final AuthenticationController controller = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||||
mock(MailDomains.class), false, guard, mock(com.mailserver.config.MailAdmins.class));
|
mock(MailDomains.class), false, guard, admins, new com.mailserver.config.JambotronSso("", "", "", "mail.x.test"));
|
||||||
|
|
||||||
private final User alice = new User();
|
private final User alice = new User();
|
||||||
|
|
||||||
@@ -56,6 +57,26 @@ class AuthenticationControllerTest {
|
|||||||
return controller.login(Mono.just(request), http).block();
|
return controller.login(Mono.just(request), http).block();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Sign in with Jambotron» on: the web password only for the built-in administrator
|
||||||
|
@Test
|
||||||
|
void withJambotronSignInOnlyTheBuiltInAdministratorLogsInWithAPassword() {
|
||||||
|
AuthenticationController sso = new AuthenticationController(users, mock(MailService.class), jwt,
|
||||||
|
mock(MailDomains.class), false, guard, admins,
|
||||||
|
new com.mailserver.config.JambotronSso("https://jambotron.test", "tok", "", "mail.x.test"));
|
||||||
|
User admin = new User();
|
||||||
|
admin.setEmail("admin@x.test");
|
||||||
|
when(users.findByEmail("admin@x.test")).thenReturn(Mono.just(admin));
|
||||||
|
when(users.checkPassword(eq(admin), anyString())).thenReturn(true);
|
||||||
|
LoginRequest request = new LoginRequest();
|
||||||
|
request.setEmail("alice@x.test");
|
||||||
|
request.setPassword("secret");
|
||||||
|
MockServerHttpRequest http = MockServerHttpRequest.post("/api/login").remoteAddress(new InetSocketAddress("9.9.9.9", 1)).build();
|
||||||
|
assertThat(sso.login(Mono.just(request), http).block().getBody())
|
||||||
|
.isEqualTo(Map.of("error", "Sign in with Jambotron — the password is only for mail programs"));
|
||||||
|
request.setEmail("admin@x.test");
|
||||||
|
assertThat(sso.login(Mono.just(request), http).block().getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
|
void unknownMailboxAndWrongPasswordLookTheSameAndBothCount() {
|
||||||
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
assertThat(login("1.2.3.4", "ghost@x.test", "x").getBody()).isEqualTo(Map.of("error", "Invalid credentials"));
|
||||||
|
|||||||
@@ -186,6 +186,32 @@ class MailboxAdminControllerTest {
|
|||||||
verify(mail, never()).deleteMailbox(admin);
|
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
|
@Test
|
||||||
void administratorOpensAnyMailboxOthersCanNot() {
|
void administratorOpensAnyMailboxOthersCanNot() {
|
||||||
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
|
when(jwt.generateToken("alice@jambocheck.com", "admin@jambocheck.com")).thenReturn("alice-token");
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.mailserver.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SsoControllerTest {
|
||||||
|
|
||||||
|
// RFC 7636 appendix B
|
||||||
|
@Test
|
||||||
|
void pkceChallengeIsS256OfTheVerifier() {
|
||||||
|
assertThat(SsoController.challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"))
|
||||||
|
.isEqualTo("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,6 +128,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_HOST: db
|
DB_HOST: db
|
||||||
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
MAILSERVER_SECRETS_DIR: /run/mail-secrets
|
||||||
|
# «Sign in with Jambotron» (OAuth code + PKCE, SERVICE_TOKEN as the client secret); empty — password login
|
||||||
|
JAMBOTRON_URL: ${JAMBOTRON_URL:-https://jambotron.com}
|
||||||
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
|
MAIL_DOMAINS: ${MAIL_DOMAINS:-jambocheck.com}
|
||||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.jambocheck.com}
|
||||||
MAIL_TLS_CERT: /tls/cert.pem
|
MAIL_TLS_CERT: /tls/cert.pem
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { authGuard, guestGuard } from './core/auth';
|
|||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
|
{ path: 'login', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/login.page').then(m => m.LoginPage) },
|
||||||
|
{ path: 'sso', loadComponent: () => import('./pages/auth/sso.page').then(m => m.SsoPage) },
|
||||||
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
|
{ path: 'register', canActivate: [guestGuard], loadComponent: () => import('./pages/auth/register.page').then(m => m.RegisterPage) },
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -15,7 +16,8 @@ export const routes: Routes = [
|
|||||||
{ path: 'compose', loadComponent: () => import('./pages/compose/compose.page').then(m => m.ComposePage) },
|
{ 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: 'contacts', loadComponent: () => import('./pages/contacts/contacts.page').then(m => m.ContactsPage) },
|
||||||
{ path: 'blocked', loadComponent: () => import('./pages/blocked/blocked.page').then(m => m.BlockedPage) },
|
{ 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: '' }
|
{ path: '**', redirectTo: '' }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models';
|
import { AppPassword, Attachment, BlacklistEntry, Contact, Draft, Folder, MailList, Mailbox, MailboxCreated, MailboxEntry, Message, Profile, ServerConfig } from './models';
|
||||||
|
|
||||||
/** The mail server REST API (same origin, /api). */
|
/** The mail server REST API (same origin, /api). */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -55,6 +55,17 @@ export class ApiService {
|
|||||||
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
setMailboxActive(id: number, active: boolean): Observable<Mailbox> {
|
||||||
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/active`, { active });
|
||||||
}
|
}
|
||||||
|
setMailboxSystem(id: number, system: boolean): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/system`, { system });
|
||||||
|
}
|
||||||
|
appPasswords(): Observable<AppPassword[]> { return this.http.get<AppPassword[]>('/api/app-passwords'); }
|
||||||
|
createAppPassword(name: string): Observable<{ appPassword: AppPassword; password: string }> {
|
||||||
|
return this.http.post<{ appPassword: AppPassword; password: string }>('/api/app-passwords', { name });
|
||||||
|
}
|
||||||
|
deleteAppPassword(id: number): Observable<void> { return this.http.delete<void>(`/api/app-passwords/${id}`); }
|
||||||
|
setMailboxRole(id: number, role: 'ADMIN' | 'USER'): Observable<Mailbox> {
|
||||||
|
return this.http.put<Mailbox>(`/api/admin/mailboxes/${id}/role`, { role });
|
||||||
|
}
|
||||||
deleteMailbox(id: number): Observable<void> {
|
deleteMailbox(id: number): Observable<void> {
|
||||||
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
|
return this.http.delete<void>(`/api/admin/mailboxes/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export class AuthService {
|
|||||||
if (token && email) { this.store({ token, email }); } else { this.clear(); }
|
if (token && email) { this.store({ token, email }); } else { this.clear(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Session from «Sign in with Jambotron» (/sso#token=…). */
|
||||||
|
signIn(response: TokenResponse): void {
|
||||||
|
this.clear();
|
||||||
|
this.store(response);
|
||||||
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.clear();
|
this.clear();
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { ApiService } from './api.service';
|
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. */
|
/** Folders and the profile, shared by the sidebar, the lists and the header; refreshed after changes. */
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -9,6 +9,8 @@ export class MailStateService {
|
|||||||
|
|
||||||
readonly folders = signal<Folder[]>([]);
|
readonly folders = signal<Folder[]>([]);
|
||||||
readonly profile = signal<Profile | null>(null);
|
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 inbox = computed(() => this.folders().find(f => f.system && f.name === 'INBOX') ?? null);
|
||||||
readonly unreadTotal = computed(() => this.inbox()?.unreadCount ?? 0);
|
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: () => {} });
|
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 {
|
reset(): void {
|
||||||
this.folders.set([]);
|
this.folders.set([]);
|
||||||
this.profile.set(null);
|
this.profile.set(null);
|
||||||
|
this.boxes.set([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export interface Mailbox {
|
|||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
quotaBytes: number;
|
quotaBytes: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
role: 'ADMIN' | 'USER'; // ADMIN — Admin panel (users, mailboxes, lists)
|
||||||
|
builtIn: boolean; // administrator from the server configuration — stays ADMIN
|
||||||
|
system: boolean; // no-reply@, monitor@ …: no web login, the built-in administrators open it
|
||||||
}
|
}
|
||||||
|
|
||||||
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
|
/** MailLists.MailList — mail to the address → a copy to every member, into the folder (null — INBOX) */
|
||||||
@@ -100,6 +103,15 @@ export interface MailboxCreated {
|
|||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
registrationEnabled: boolean;
|
registrationEnabled: boolean;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
|
ssoEnabled?: boolean; // «Sign in with Jambotron»; then the web password is only for the built-in administrator
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AppPasswords.AppPassword — a password for one mail program (SMTP / IMAP only) */
|
||||||
|
export interface AppPassword {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
|
|||||||
@@ -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: people with a login of their own (not shared or system 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 && !m.system));
|
||||||
|
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))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { AuthService } from '../../core/auth.service';
|
import { AuthService } from '../../core/auth.service';
|
||||||
@@ -12,8 +12,24 @@ import { AuthFrameComponent } from './auth-frame.component';
|
|||||||
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
|
imports: [FormsModule, RouterLink, MatIconModule, AuthFrameComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<app-auth-frame title="LOG IN" subtitle="Your mailbox address and password">
|
<app-auth-frame title="LOG IN" [subtitle]="sso() ? 'With your Jambotron account' : 'Your mailbox address and password'">
|
||||||
<form (ngSubmit)="submit()" class="space-y-4">
|
@if (sso()) {
|
||||||
|
<a href="/api/oauth/start" class="cyber-btn w-full py-3 rounded-xl font-bold text-sm mb-3">
|
||||||
|
<mat-icon>login</mat-icon> SIGN IN WITH JAMBOTRON
|
||||||
|
</a>
|
||||||
|
<p class="text-xs text-slate-400 font-rajdhani text-center mb-4">The same login as on jambotron.com — Google, GitHub or password. For Jambotron staff.</p>
|
||||||
|
@if (ssoError()) {
|
||||||
|
<p class="text-sm text-pink-400 font-rajdhani flex items-center gap-2 mb-4" role="alert">
|
||||||
|
<mat-icon class="!text-lg">error</mat-icon>{{ ssoError() }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button type="button" class="text-xs text-slate-500 hover:text-cyan-300 font-mono w-full text-center"
|
||||||
|
[attr.aria-expanded]="adminLogin()" (click)="adminLogin.set(!adminLogin())">
|
||||||
|
{{ adminLogin() ? 'Hide administrator login' : 'Administrator login (password)' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (!sso() || adminLogin()) {
|
||||||
|
<form (ngSubmit)="submit()" class="space-y-4" [class.mt-4]="sso()">
|
||||||
<div>
|
<div>
|
||||||
<label class="synth-label" for="email">Email</label>
|
<label class="synth-label" for="email">Email</label>
|
||||||
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
|
<input id="email" name="email" type="email" class="synth-input" [(ngModel)]="email" required
|
||||||
@@ -39,6 +55,7 @@ import { AuthFrameComponent } from './auth-frame.component';
|
|||||||
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
|
<mat-icon>login</mat-icon> {{ busy() ? 'LOGGING IN…' : 'LOG IN' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
}
|
||||||
@if (registration()) {
|
@if (registration()) {
|
||||||
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
<p class="mt-6 text-center text-sm text-slate-400 font-rajdhani">
|
||||||
No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
|
No mailbox yet? <a routerLink="/register" class="text-cyan-300 hover:text-pink-400 font-semibold">Create one</a>
|
||||||
@@ -57,9 +74,15 @@ export class LoginPage {
|
|||||||
readonly busy = signal(false);
|
readonly busy = signal(false);
|
||||||
readonly error = signal('');
|
readonly error = signal('');
|
||||||
readonly registration = signal(false);
|
readonly registration = signal(false);
|
||||||
|
readonly sso = signal(false);
|
||||||
|
readonly adminLogin = signal(false);
|
||||||
|
readonly ssoError = signal(inject(ActivatedRoute).snapshot.queryParamMap.get('sso_error') ?? '');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
inject(ApiService).config().subscribe({ next: c => this.registration.set(c.registrationEnabled), error: () => {} });
|
inject(ApiService).config().subscribe({
|
||||||
|
next: c => { this.registration.set(c.registrationEnabled); this.sso.set(!!c.ssoEnabled); },
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
|
||||||
|
/** «Sign in with Jambotron» lands here: /sso#token=…&email=… → session → mailbox. */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sso',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
template: `<p class="p-8 text-center text-slate-300 font-mono" role="status">Signing in…</p>`
|
||||||
|
})
|
||||||
|
export class SsoPage {
|
||||||
|
constructor() {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const router = inject(Router);
|
||||||
|
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||||
|
const token = params.get('token');
|
||||||
|
const email = params.get('email');
|
||||||
|
history.replaceState(null, '', '/sso'); // the token does not stay in the address bar / history
|
||||||
|
if (token && email) {
|
||||||
|
auth.signIn({ token, email });
|
||||||
|
router.navigate(['/']);
|
||||||
|
} else {
|
||||||
|
router.navigate(['/login'], { queryParams: { sso_error: 'Sign-in did not finish — try again' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { ApiService } from '../../core/api.service';
|
||||||
|
import { AppPassword, errorText } from '../../core/models';
|
||||||
|
import { DialogService } from '../../ui/dialog.service';
|
||||||
|
import { ToastService } from '../../ui/toast.service';
|
||||||
|
|
||||||
|
/** User panel → APP PASSWORDS: Thunderbird, phone … — SMTP / IMAP only; the web client is «Sign in with Jambotron». */
|
||||||
|
@Component({
|
||||||
|
selector: 'app-app-passwords-section',
|
||||||
|
imports: [DatePipe, FormsModule, MatIconModule],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'block' },
|
||||||
|
template: `
|
||||||
|
<section class="glass-panel static p-5 md:p-6 h-full" aria-labelledby="app-pw-title">
|
||||||
|
<h2 id="app-pw-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">key</mat-icon> APP PASSWORDS
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-slate-400 font-mono mb-3">For a mail program (Thunderbird, phone): login — your mailbox address,
|
||||||
|
password — an app password. IMAP and SMTP on this server. Not for the web.</p>
|
||||||
|
@if (secret(); as s) {
|
||||||
|
<div class="p-3 mb-3 rounded-lg border border-amber-400/50 bg-amber-400/10" role="status">
|
||||||
|
<div class="text-xs text-amber-200 mb-1">«{{ s.name }}» — copy it now, it is shown only once:</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-base text-amber-100 select-all break-all">{{ s.password }}</code>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="copy(s.password)" aria-label="Copy password"><mat-icon>content_copy</mat-icon></button>
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="secret.set(null)" aria-label="Hide password"><mat-icon>close</mat-icon></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<ul class="space-y-1 mb-3">
|
||||||
|
@for (p of list(); track p.id) {
|
||||||
|
<li class="flex items-center gap-2 px-2 py-1 rounded-lg border border-cyan-500/15 text-sm">
|
||||||
|
<mat-icon class="!text-lg text-slate-400">devices</mat-icon>
|
||||||
|
<span class="flex-grow truncate text-slate-200">{{ p.name }}</span>
|
||||||
|
<span class="text-[11px] font-mono text-slate-500">{{ p.lastUsedAt ? 'used ' + (p.lastUsedAt | date:'d MMM HH:mm') : 'not used yet' }}</span>
|
||||||
|
<button type="button" class="ghost-btn !py-1 hover:text-rose-400" (click)="remove(p)" [attr.aria-label]="'Revoke ' + p.name"
|
||||||
|
title="Revoke — the program stops getting mail"><mat-icon>delete</mat-icon></button>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="text-xs text-slate-500 font-mono px-1">No app passwords yet</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<form class="flex items-end gap-2" (ngSubmit)="create()">
|
||||||
|
<div class="flex-grow"><label class="synth-label" for="app-pw-name">Program</label>
|
||||||
|
<input id="app-pw-name" name="name" class="synth-input" [(ngModel)]="name" placeholder="Thunderbird at home" autocomplete="off"></div>
|
||||||
|
<button type="submit" class="cyber-btn px-4 py-2 rounded-lg text-xs font-bold"><mat-icon>add</mat-icon> NEW</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class AppPasswordsSection {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private dialogs = inject(DialogService);
|
||||||
|
private toasts = inject(ToastService);
|
||||||
|
|
||||||
|
readonly list = signal<AppPassword[]>([]);
|
||||||
|
readonly secret = signal<{ name: string; password: string } | null>(null);
|
||||||
|
name = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
load(): void {
|
||||||
|
this.api.appPasswords().subscribe({ next: l => this.list.set(l), error: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
create(): void {
|
||||||
|
this.api.createAppPassword(this.name.trim()).subscribe({
|
||||||
|
next: r => { this.secret.set({ name: r.appPassword.name, password: r.password }); this.name = ''; this.load(); },
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(p: AppPassword): void {
|
||||||
|
this.dialogs.confirm(`Revoke «${p.name}»?`, 'That program stops getting and sending mail until it gets a new app password.', 'Revoke', true)
|
||||||
|
.subscribe(ok => {
|
||||||
|
if (ok === null) return;
|
||||||
|
this.api.deleteAppPassword(p.id).subscribe({ next: () => 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,11 +66,12 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
<li class="flex flex-wrap items-center gap-x-4 gap-y-1 py-2">
|
<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="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>
|
<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"
|
@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> }
|
[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"
|
@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"
|
||||||
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
|
[title]="'Also opened by: ' + m.members.join(', ')">+{{ m.members.length }}</span> }
|
||||||
|
@if (m.system) { <span class="text-[10px] font-mono px-1.5 rounded border border-violet-400/50 text-violet-300"
|
||||||
|
title="System mailbox: no web login; the built-in administrators open it, programs use its password">SYSTEM</span> }
|
||||||
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
|
@if (m.trap) { <span class="text-[10px] font-mono px-1.5 rounded border border-amber-400/50 text-amber-300"
|
||||||
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
|
title="What it sends is kept in its own INBOX — nothing leaves the server">TRAP</span> }
|
||||||
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
<span class="text-xs font-mono text-slate-500 ml-auto">{{ size(m.usedBytes) }}</span>
|
||||||
@@ -84,6 +85,12 @@ import { AuthService } from '../../core/auth.service';
|
|||||||
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
|
[attr.aria-label]="(m.trap ? 'Trap off for ' : 'Trap on for ') + m.email"
|
||||||
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
|
[title]="m.trap ? 'Trap ON: sent mail stays in its INBOX. Click to send for real' : 'Trap: keep what it sends in its own INBOX'">
|
||||||
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
|
<mat-icon>{{ m.trap ? 'move_to_inbox' : 'outbox' }}</mat-icon></button>
|
||||||
|
@if (!m.shared && !m.admin) {
|
||||||
|
<button type="button" class="ghost-btn !py-1" (click)="system(m)" [class.text-violet-300]="m.system"
|
||||||
|
[attr.aria-label]="(m.system ? 'Not system: ' : 'Make system: ') + m.email"
|
||||||
|
[title]="m.system ? 'System mailbox — click to make it ordinary' : 'Make it a system mailbox (no-reply@, monitor@ …)'">
|
||||||
|
<mat-icon>dns</mat-icon></button>
|
||||||
|
}
|
||||||
@if (!m.shared) {
|
@if (!m.shared) {
|
||||||
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
<button type="button" class="ghost-btn !py-1" (click)="newPassword(m)" [attr.aria-label]="'New password for ' + m.email"><mat-icon>key</mat-icon></button>
|
||||||
}
|
}
|
||||||
@@ -196,6 +203,16 @@ export class MailboxesSection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
|
/** Trap on: what the mailbox sends is kept in its own INBOX (staging@). Off: it sends for real again. */
|
||||||
|
system(m: Mailbox): void {
|
||||||
|
const on = !m.system;
|
||||||
|
if (!confirm(on ? `${m.email} becomes a system mailbox: no web login, the built-in administrators open it. Programs keep its password.`
|
||||||
|
: `${m.email} becomes an ordinary mailbox again.`)) return;
|
||||||
|
this.api.setMailboxSystem(m.id, on).subscribe({
|
||||||
|
next: () => { this.toasts.ok(m.email + (on ? ' is a system mailbox' : ' is ordinary')); this.load(); },
|
||||||
|
error: e => this.toasts.error(errorText(e))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
trap(m: Mailbox): void {
|
trap(m: Mailbox): void {
|
||||||
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
|
if (m.trap && !confirm('Switch the trap off? ' + m.email + ' will send mail to real people again.')) return;
|
||||||
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
|
this.api.setMailboxTrap(m.id, !m.trap).subscribe({
|
||||||
|
|||||||
@@ -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,13 @@ import { MailStateService } from '../../core/mail-state.service';
|
|||||||
import { errorText, formatBytes } from '../../core/models';
|
import { errorText, formatBytes } from '../../core/models';
|
||||||
import { THEMES, ThemeService } from '../../ui/theme.service';
|
import { THEMES, ThemeService } from '../../ui/theme.service';
|
||||||
import { ToastService } from '../../ui/toast.service';
|
import { ToastService } from '../../ui/toast.service';
|
||||||
import { MailboxesSection } from './mailboxes.section';
|
import { MyMailboxesSection } from './my-mailboxes.section';
|
||||||
import { ListsSection } from './lists.section';
|
import { AppPasswordsSection } from './app-passwords.section';
|
||||||
|
|
||||||
/** Profile name, password, storage, look. */
|
/** User panel: profile name, password, storage, look, my mailboxes. Administration — Admin panel (/admin). */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings',
|
selector: 'app-settings',
|
||||||
imports: [DatePipe, FormsModule, MatIconModule, MailboxesSection, ListsSection],
|
imports: [DatePipe, FormsModule, MatIconModule, MyMailboxesSection, AppPasswordsSection],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
<div class="grid grid-cols-1 xl:grid-cols-2 gap-5">
|
||||||
@@ -93,7 +93,7 @@ import { ListsSection } from './lists.section';
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
@for (t of themes; track t.value) {
|
@for (t of themes; track t.value) {
|
||||||
<button type="button" class="ghost-btn justify-center !py-2" [attr.aria-pressed]="theme.theme() === 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>
|
(click)="theme.theme.set(t.value)">{{ t.label }}</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,10 +112,9 @@ import { ListsSection } from './lists.section';
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@if (state.profile()?.admin) {
|
<app-my-mailboxes-section />
|
||||||
<app-mailboxes-section />
|
<app-app-passwords-section />
|
||||||
<app-lists-section />
|
|
||||||
}
|
|
||||||
</div>
|
</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">
|
<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()"
|
<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">
|
aria-label="Mail folders">
|
||||||
<div class="flex items-center justify-between mb-2 px-1">
|
<label for="mailbox-pick" class="block text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest mb-2 px-1">MAILBOX</label>
|
||||||
<h2 class="text-[11px] font-orbitron font-bold text-cyan-300 tracking-widest">MAILBOXES</h2>
|
<select id="mailbox-pick" class="synth-input !py-1.5 !text-sm font-mono mb-4" [ngModel]="auth.email()"
|
||||||
<button type="button" class="text-slate-400 hover:text-cyan-300" matTooltip="Add a mailbox you have the password of"
|
(ngModelChange)="pick($event)" [disabled]="state.boxes().length < 2">
|
||||||
aria-label="Add mailbox" [attr.aria-expanded]="attachOpen()" (click)="attachOpen.set(!attachOpen())">
|
@for (b of state.boxes(); track b.email) {
|
||||||
<mat-icon class="!text-xl">add_circle_outline</mat-icon></button>
|
<option [value]="b.email">{{ b.email }}{{ b.shared ? ' · shared' : b.kind === 'ATTACHED' ? ' · attached' : '' }}{{ b.unread ? ' (' + b.unread + ')' : '' }}</option>
|
||||||
</div>
|
} @empty {
|
||||||
<ul class="space-y-1 mb-3" aria-label="Your mailboxes">
|
<option [value]="auth.email()">{{ auth.email() }}</option>
|
||||||
@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>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</select>
|
||||||
@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>
|
|
||||||
}
|
|
||||||
|
|
||||||
<a routerLink="/compose" class="cyber-btn w-full py-2.5 rounded-xl font-bold text-xs mb-4" (click)="menuOpen.set(false)">
|
<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
|
<mat-icon>edit</mat-icon> COMPOSE
|
||||||
@@ -126,7 +98,7 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
@for (folder of systemFolders(); track folder.id) {
|
@for (folder of systemFolders(); track folder.id) {
|
||||||
<li>
|
<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"
|
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)">
|
(click)="menuOpen.set(false)">
|
||||||
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
|
<mat-icon class="!text-xl">{{ icon(folder) }}</mat-icon>
|
||||||
@@ -148,7 +120,7 @@ import { ToastService } from '../../ui/toast.service';
|
|||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
@for (folder of ownFolders(); track folder.id) {
|
@for (folder of ownFolders(); track folder.id) {
|
||||||
<li class="flex items-center gap-1">
|
<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"
|
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)">
|
(click)="menuOpen.set(false)">
|
||||||
<mat-icon class="!text-xl">folder</mat-icon>
|
<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">
|
<div class="border-t border-cyan-500/20 mt-5 pt-3 space-y-1">
|
||||||
@for (link of links; track link.path) {
|
@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"
|
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)">
|
(click)="menuOpen.set(false)">
|
||||||
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
|
<mat-icon class="!text-xl">{{ link.icon }}</mat-icon>{{ link.label }}
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (state.profile(); as p) {
|
@if (state.profile(); as p) {
|
||||||
@@ -203,17 +182,12 @@ export class ShellComponent {
|
|||||||
|
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
readonly menuOpen = signal(false);
|
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 = '';
|
query = '';
|
||||||
|
|
||||||
readonly links = [
|
readonly links = [
|
||||||
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
|
{ path: '/contacts', label: 'Contacts', icon: 'contacts' },
|
||||||
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
|
{ path: '/blocked', label: 'Blocked senders', icon: 'block' },
|
||||||
{ path: '/settings', label: 'Settings', icon: 'settings' }
|
{ path: '/settings', label: 'User panel', icon: 'person' }
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly folderActions: RowAction[] = [
|
readonly folderActions: RowAction[] = [
|
||||||
@@ -238,8 +212,8 @@ export class ShellComponent {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.state.refreshFolders();
|
this.state.refreshFolders();
|
||||||
this.state.refreshProfile();
|
this.state.refreshProfile();
|
||||||
this.loadBoxes();
|
this.state.refreshBoxes();
|
||||||
const timer = setInterval(() => { this.state.refreshFolders(); this.loadBoxes(); }, 30000);
|
const timer = setInterval(() => { this.state.refreshFolders(); this.state.refreshBoxes(); }, 30000);
|
||||||
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
inject(DestroyRef).onDestroy(() => clearInterval(timer));
|
||||||
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||||
const q = this.router.parseUrl(this.router.url).queryParams['q'];
|
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. */
|
/** Another mailbox of this login: its session, full reload — fresh folders and profile. */
|
||||||
switchTo(b: MailboxEntry): void {
|
switchTo(b: MailboxEntry): void {
|
||||||
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
if (b.email === this.auth.email()) { this.router.navigate(['/']); return; }
|
||||||
@@ -313,37 +283,9 @@ export class ShellComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
attach(): void {
|
pick(email: string): void {
|
||||||
const email = this.attachEmail.trim().toLowerCase();
|
const b = this.state.boxes().find(x => x.email === email);
|
||||||
if (!email || !this.attachPassword) { this.toasts.error('Mailbox and its password'); return; }
|
if (b) this.switchTo(b);
|
||||||
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))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
backToAdmin(): void {
|
backToAdmin(): void {
|
||||||
|
|||||||
@@ -300,6 +300,15 @@ 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: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; }
|
.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 */
|
/* 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 { color-scheme: dark; }
|
||||||
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
|
select option, select optgroup { background-color: #0f172a; color: #e2e8f0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user