Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -95,3 +99,5 @@ processResources {
expand props
}
}

test { useJUnitPlatform() }
14 changes: 5 additions & 9 deletions src/main/java/net/democracycraft/vault/VaultStoragePlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -124,10 +123,7 @@ public void onEnable() {
this.boltService = new BoltServiceImp(this);
getServer().getServicesManager().register(BoltService.class, this.boltService, this, ServicePriority.Normal);

RegisteredServiceProvider<MailService> 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) {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Path> 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"); }
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<MailConfig> yml = AutoYML.create(MailConfig.class, "mail", DataFolder.MAIL, MailConfig.HEADER);
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NotificationOutbox.Notice> 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<NotificationOutbox.Notice> 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());
}
}
Loading