diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1f6fa3..b935626 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,11 +20,11 @@ jobs: - name: Checkout uses: actions/checkout@v6 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: distribution: temurin - java-version: '21' + java-version: '25' - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 diff --git a/README.md b/README.md index 14ba06d..854200a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Command (in‑game): ### Requirements - Paper 1.21.8 (or compatible) -- Java 21 +- Java 25 - WorldEdit and WorldGuard installed - Bolt installed (for ownership/protection integration) @@ -190,3 +190,15 @@ Concurrency and threading: Developed and maintained by **Alepando** for **[DemocracyCraft.net](https://democracycraft.net)** as the **Lead Developer** of the Vault plugin. + +### Owner notifications + +Vault creation notices use the existing PlayerNotifications mail inbox when available. The sender, +recipient, configured message template and text are retained; notices do not expire. Before delivery, +each notice is saved under `pending-notifications/`. If the provider is unavailable or rejects it, the +file remains and delivery retries once a minute and after restart. Preserve pending files until the +provider accepts them. A write failure is logged with the unsaved notice; the created vault and its +stored contents remain intact. Existing Essentials mail is never converted or deleted. + +The retry key stays stable across restarts. If delivery succeeds but removing its pending file fails, +the file is retained for operator inspection; the adapter does not overwrite an existing inbox entry. diff --git a/build.gradle b/build.gradle index 8845ae7..3a4cc6a 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,7 @@ version = '1.2.0' repositories { mavenCentral() + maven { url = "https://maven.minecraftcitiesnetwork.com/releases" } maven { name = "papermc" url = "https://repo.papermc.io/repository/maven-public/" @@ -50,6 +51,9 @@ repositories { } dependencies { + compileOnly("io.github.md5sha256:player-notifications-api:1.2.0") { transitive = false } + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") String democracyLibVersion = "e5ddf4ac24" String democracyLib = "com.github.MCCitiesNetwork:DemocracyLib:$democracyLibVersion" implementation(democracyLib) @@ -95,3 +99,5 @@ processResources { expand props } } + +test { useJUnitPlatform() } diff --git a/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java b/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java index 021190b..aee167c 100644 --- a/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java +++ b/src/main/java/net/democracycraft/vault/VaultStoragePlugin.java @@ -24,11 +24,10 @@ import net.democracycraft.vault.internal.ui.VaultPlacementMenu; import net.democracycraft.vault.internal.ui.LoadingMenu; import net.democracycraft.vault.internal.security.VaultPermission; -import net.essentialsx.api.v2.services.mail.MailService; +import net.democracycraft.vault.internal.notification.OfflineNotifications; import org.bukkit.Bukkit; import org.bukkit.World; import org.bukkit.permissions.Permission; -import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.ServicePriority; import org.bukkit.plugin.java.JavaPlugin; import org.geysermc.floodgate.api.FloodgateApi; @@ -54,7 +53,7 @@ public final class VaultStoragePlugin extends JavaPlugin { private VaultService vaultService; private WorldGuardService worldGuardService; private BoltService boltService; - private MailService mailService; + private OfflineNotifications offlineNotifications; private Essentials essentials; private ChestShopService chestShopService; private DemocracyLibApi democracyLibApi; @@ -124,10 +123,7 @@ public void onEnable() { this.boltService = new BoltServiceImp(this); getServer().getServicesManager().register(BoltService.class, this.boltService, this, ServicePriority.Normal); - RegisteredServiceProvider mailService = getServer().getServicesManager().getRegistration(MailService.class); - if(mailService != null) { - this.mailService = mailService.getProvider(); - } + this.offlineNotifications = new OfflineNotifications(this); var essentials = getServer().getPluginManager().getPlugin("Essentials"); if (essentials instanceof Essentials ess) { @@ -218,8 +214,8 @@ public void onDisable() { if (this.mysql != null) this.mysql.disconnect(); } - public MailService getMailService() { - return mailService; + public OfflineNotifications getOfflineNotifications() { + return offlineNotifications; } public Essentials getEssentials() { diff --git a/src/main/java/net/democracycraft/vault/internal/notification/NotificationOutbox.java b/src/main/java/net/democracycraft/vault/internal/notification/NotificationOutbox.java new file mode 100644 index 0000000..ddd12cd --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/notification/NotificationOutbox.java @@ -0,0 +1,82 @@ +package net.democracycraft.vault.internal.notification; + +import java.io.*; +import java.nio.channels.FileChannel; +import java.nio.file.*; +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** Keeps offline notices until the inbox provider accepts them. Never reads legacy mail. */ +public final class NotificationOutbox { + public record Notice(UUID id, UUID recipient, UUID sender, String senderName, String message, java.time.Instant createdAt) { + public Notice { + Objects.requireNonNull(id); Objects.requireNonNull(recipient); Objects.requireNonNull(sender); + Objects.requireNonNull(senderName); Objects.requireNonNull(message); Objects.requireNonNull(createdAt); + } + } + @FunctionalInterface public interface Delivery { boolean deliver(Notice notice) throws Exception; } + private final Path directory; + private final Delivery delivery; + private final Logger logger; + + public NotificationOutbox(Path directory, Delivery delivery, Logger logger) { + this.directory = Objects.requireNonNull(directory); + this.delivery = Objects.requireNonNull(delivery); + this.logger = Objects.requireNonNull(logger); + } + + /** Returns true when accepted by the inbox; false means durably queued for retry. */ + public synchronized boolean submit(UUID recipient, UUID sender, String senderName, String message) throws IOException { + Notice notice = new Notice(UUID.randomUUID(), recipient, sender, senderName, message, java.time.Instant.now()); + Files.createDirectories(directory); + Properties values = new Properties(); + values.setProperty("id", notice.id().toString()); + values.setProperty("recipient", recipient.toString()); + values.setProperty("sender", sender.toString()); + values.setProperty("senderName", senderName); + values.setProperty("message", message); + values.setProperty("createdAt", notice.createdAt().toString()); + Path temporary = Files.createTempFile(directory, "notice-", ".tmp"); + try { + try (OutputStream output = Files.newOutputStream(temporary)) { values.store(output, "Pending offline notification"); } + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); } + Files.move(temporary, file(notice.id()), StandardCopyOption.ATOMIC_MOVE); + } finally { Files.deleteIfExists(temporary); } + return attempt(notice); + } + + public synchronized void flush() { + if (!Files.isDirectory(directory)) return; + try (DirectoryStream files = Files.newDirectoryStream(directory, "*.properties")) { + for (Path file : files) { + try { + Properties values = new Properties(); + try (InputStream input = Files.newInputStream(file)) { values.load(input); } + Notice notice = new Notice(UUID.fromString(values.getProperty("id")), + UUID.fromString(values.getProperty("recipient")), UUID.fromString(values.getProperty("sender")), + values.getProperty("senderName"), values.getProperty("message"), + java.time.Instant.parse(values.getProperty("createdAt"))); + if (!file.getFileName().toString().equals(notice.id() + ".properties")) { + throw new IOException("Notification filename does not match its id"); + } + attempt(notice); + } catch (Exception exception) { + logger.log(Level.WARNING, "Pending notification retained: " + file.getFileName(), exception); + } + } + } catch (IOException exception) { logger.log(Level.WARNING, "Cannot read pending notifications", exception); } + } + + private boolean attempt(Notice notice) { + try { + if (!delivery.deliver(notice)) return false; + Files.delete(file(notice.id())); + return true; + } catch (Exception | LinkageError exception) { + logger.log(Level.WARNING, "Notification " + notice.id() + " retained for retry", exception); + return false; + } + } + private Path file(UUID id) { return directory.resolve(id + ".properties"); } +} diff --git a/src/main/java/net/democracycraft/vault/internal/notification/OfflineNotifications.java b/src/main/java/net/democracycraft/vault/internal/notification/OfflineNotifications.java new file mode 100644 index 0000000..fc48c98 --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/notification/OfflineNotifications.java @@ -0,0 +1,25 @@ +package net.democracycraft.vault.internal.notification; + +import org.bukkit.plugin.Plugin; +import java.io.IOException; +import java.util.UUID; + +/** Optional inbox bridge with a durable fallback when the provider is unavailable. */ +public final class OfflineNotifications { + private final NotificationOutbox outbox; + public OfflineNotifications(Plugin plugin) { + outbox = new NotificationOutbox(plugin.getDataFolder().toPath().resolve("pending-notifications"), notice -> { + if (!plugin.getServer().getPluginManager().isPluginEnabled("PlayerNotifications")) return false; + try { + Class.forName("io.github.md5sha256.playernotifications.api.NotificationService", false, + plugin.getClass().getClassLoader()); + } catch (ClassNotFoundException exception) { return false; } + return PlayerNotificationsDelivery.deliver(plugin, notice); + }, plugin.getLogger()); + // Bukkit owns/cancels this task with the plugin; pending files survive a restart. + plugin.getServer().getScheduler().runTaskTimer(plugin, outbox::flush, 1L, 1200L); + } + public boolean send(UUID recipient, UUID sender, String senderName, String message) throws IOException { + return outbox.submit(recipient, sender, senderName, message); + } +} diff --git a/src/main/java/net/democracycraft/vault/internal/notification/PlayerNotificationsDelivery.java b/src/main/java/net/democracycraft/vault/internal/notification/PlayerNotificationsDelivery.java new file mode 100644 index 0000000..42942ba --- /dev/null +++ b/src/main/java/net/democracycraft/vault/internal/notification/PlayerNotificationsDelivery.java @@ -0,0 +1,23 @@ +package net.democracycraft.vault.internal.notification; + +import io.github.md5sha256.playernotifications.api.NotificationService; +import io.github.md5sha256.playernotifications.api.NotificationTarget; +import io.github.md5sha256.playernotifications.api.TypedNotification; +import io.github.md5sha256.playernotifications.api.mail.MailPayload; +import org.bukkit.plugin.Plugin; +import java.util.List; + +/** Loaded only after the optional PlayerNotifications API has been located. */ +final class PlayerNotificationsDelivery { + static boolean deliver(Plugin plugin, NotificationOutbox.Notice notice) { + NotificationService service = plugin.getServer().getServicesManager().load(NotificationService.class); + if (service == null) return false; + service.enqueueNotification(new TypedNotification<>("vaultstorage-" + notice.id(), notice.createdAt(), null, + new NotificationTarget(List.of(notice.recipient())), MailPayload.DATA_TYPE, + new MailPayload(notice.sender(), notice.senderName(), + net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().serialize( + net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection() + .deserialize(notice.message()))), 0), false); + return true; + } +} diff --git a/src/main/java/net/democracycraft/vault/internal/service/VaultServiceImpl.java b/src/main/java/net/democracycraft/vault/internal/service/VaultServiceImpl.java index 308d141..a633042 100644 --- a/src/main/java/net/democracycraft/vault/internal/service/VaultServiceImpl.java +++ b/src/main/java/net/democracycraft/vault/internal/service/VaultServiceImpl.java @@ -47,16 +47,10 @@ public VaultServiceImpl(@NotNull VaultDAO dao) { dao.createVault(vaultEntity, ownerUuid); Essentials essentials = VaultStoragePlugin.getInstance().getEssentials(); - User sender = essentials.getUser(actor); - - - - User recipient = essentials.getUser(ownerUuid); - if(sender == null || recipient == null) { - return vaultEntity; - } - String senderName = sender.getName(); - String recipientName = recipient.getName(); + User sender = essentials == null ? null : essentials.getUser(actor); + User recipient = essentials == null ? null : essentials.getUser(ownerUuid); + String senderName = sender == null || sender.getName() == null ? actor.toString() : sender.getName(); + String recipientName = recipient == null || recipient.getName() == null ? ownerUuid.toString() : recipient.getName(); // Load configurable MiniMessage template and apply placeholders AutoYML yml = AutoYML.create(MailConfig.class, "mail", DataFolder.MAIL, MailConfig.HEADER); @@ -69,7 +63,19 @@ public VaultServiceImpl(@NotNull VaultDAO dao) { String message = LegacyComponentSerializer.legacySection() .serialize(MiniMessageUtil.parseOrPlain(cfg.vaultCreatedMessage, placeholders)); - VaultStoragePlugin.getInstance().getMailService().sendMail(recipient, sender, message); + try { + boolean delivered = VaultStoragePlugin.getInstance().getOfflineNotifications() + .send(ownerUuid, actor, senderName, message); + if (!delivered) { + VaultStoragePlugin.getInstance().getLogger().warning( + "Vault " + vaultEntity.uuid + " created; owner notification is queued for inbox delivery."); + } + } catch (java.io.IOException exception) { + // The vault already exists. A notification failure must not undo storage or lose its contents. + VaultStoragePlugin.getInstance().getLogger().log(java.util.logging.Level.SEVERE, + "Vault " + vaultEntity.uuid + " created but its owner notice could not be queued. Recipient: " + + ownerUuid + "; sender: " + actor + "; notice: " + message, exception); + } return vaultEntity; } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 20097fc..737cb12 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ main: net.democracycraft.vault.VaultStoragePlugin api-version: '1.21' authors: [ Alepando ] depend: [ Bolt , WorldGuard, WorldEdit, Essentials ] -softdepend: [ ChestShop, Realty ] +softdepend: [ ChestShop, Realty, PlayerNotifications ] commands: vault: diff --git a/src/test/java/net/democracycraft/vault/internal/notification/NotificationOutboxTest.java b/src/test/java/net/democracycraft/vault/internal/notification/NotificationOutboxTest.java new file mode 100644 index 0000000..385fa1a --- /dev/null +++ b/src/test/java/net/democracycraft/vault/internal/notification/NotificationOutboxTest.java @@ -0,0 +1,59 @@ +package net.democracycraft.vault.internal.notification; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; +import static org.junit.jupiter.api.Assertions.*; + +class NotificationOutboxTest { + @TempDir Path directory; + private final UUID recipient = UUID.randomUUID(), sender = UUID.randomUUID(); + private NotificationOutbox outbox(NotificationOutbox.Delivery delivery) { + return new NotificationOutbox(directory, delivery, Logger.getLogger("outbox-test")); + } + private long pending() throws Exception { try (var files = Files.list(directory)) { return files.filter(p -> p.toString().endsWith(".properties")).count(); } } + @Test void unavailableProviderRetainsCompleteNoticeAcrossRestart() throws Exception { + assertFalse(outbox(n -> false).submit(recipient, sender, "Console", "§aJob changed\n雪")); + assertEquals(1, pending()); + List accepted = new ArrayList<>(); + outbox(n -> { accepted.add(n); return true; }).flush(); + assertEquals(1, accepted.size()); + var n = accepted.getFirst(); + assertEquals(recipient, n.recipient()); assertEquals(sender, n.sender()); + assertEquals("Console", n.senderName()); assertEquals("§aJob changed\n雪", n.message()); + assertNotNull(n.createdAt()); assertEquals(0, pending()); + } + @Test void failedDeliveryRetainsStableKeyAndTimestampForRetry() throws Exception { + List attempts = new ArrayList<>(); + var queue = outbox(n -> { attempts.add(n); throw new IllegalStateException("provider down"); }); + assertFalse(queue.submit(recipient, sender, "Actor", "Notice")); + assertEquals(1, pending()); + outbox(n -> { attempts.add(n); return true; }).flush(); + assertEquals(attempts.get(0), attempts.get(1)); assertEquals(0, pending()); + } + @Test void absentOptionalApiKeepsNotice() throws Exception { + assertFalse(outbox(n -> { throw new NoClassDefFoundError("optional API"); }).submit(recipient, sender, "Actor", "Notice")); + assertEquals(1, pending()); + } + @Test void deliveryOccursOnlyAfterDurableWrite() throws Exception { + assertTrue(outbox(n -> { assertTrue(Files.exists(directory.resolve(n.id()+".properties"))); return true; }) + .submit(recipient, sender, "Actor", "Notice")); + assertEquals(0, pending()); + } + @Test void failedPersistenceNeverClaimsDelivery() throws Exception { + Path occupied = directory.resolve("not-a-directory"); Files.writeString(occupied, "preserve"); + AtomicInteger calls = new AtomicInteger(); + var queue = new NotificationOutbox(occupied, n -> { calls.incrementAndGet(); return true; }, Logger.getLogger("test")); + assertThrows(java.io.IOException.class, () -> queue.submit(recipient, sender, "Actor", "Notice")); + assertEquals(0, calls.get()); assertEquals("preserve", Files.readString(occupied)); + } + @Test void corruptPendingFileIsRetainedAndDoesNotBlockOtherNotices() throws Exception { + Path corrupt = directory.resolve(UUID.randomUUID()+".properties"); Files.writeString(corrupt, "invalid=yes"); + assertFalse(outbox(n -> false).submit(recipient, sender, "Actor", "Notice")); + AtomicInteger calls = new AtomicInteger(); outbox(n -> { calls.incrementAndGet(); return true; }).flush(); + assertEquals(1, calls.get()); assertTrue(Files.exists(corrupt)); assertEquals(1, pending()); + } +}