Browse Source

preparing message system:

copied model from previous java implementation
feature/document
Stephan Richter 4 months ago
parent
commit
c934e19837
  1. 32
      core/build.gradle.kts
  2. 1
      core/src/main/java/.gitignore
  3. 14
      messages/build.gradle.kts
  4. 18
      messages/src/main/java/de/srsoftware/umbrella/message/Constants.java
  5. 11
      messages/src/main/java/de/srsoftware/umbrella/message/MessageDb.java
  6. 126
      messages/src/main/java/de/srsoftware/umbrella/message/SqliteDb.java
  7. 37
      messages/src/main/java/de/srsoftware/umbrella/message/model/Attachment.java
  8. 70
      messages/src/main/java/de/srsoftware/umbrella/message/model/CombinedMessage.java
  9. 57
      messages/src/main/java/de/srsoftware/umbrella/message/model/Envelope.java
  10. 53
      messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java
  11. 60
      messages/src/main/java/de/srsoftware/umbrella/message/model/Message.java
  12. 38
      messages/src/main/java/de/srsoftware/umbrella/message/model/Settings.java
  13. 6
      settings.gradle.kts
  14. 19
      user/src/main/java/de/srsoftware/umbrella/user/model/User.java

32
core/build.gradle.kts

@ -18,6 +18,34 @@ dependencies { @@ -18,6 +18,34 @@ dependencies {
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.test {
useJUnitPlatform()
tasks.jar {
manifest.attributes["Main-Class"] = "de.srsoftware.umbrella.core.Launcher"
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
val dependencies = configurations
.runtimeClasspath
.get()
.map(::zipTree) // OR .map { zipTree(it) }
from(dependencies)
}
fun download(url : String, destination : String){
var destFile = projectDir.toPath().resolve(destination).toFile();
destFile.parentFile.mkdirs()
if (!destFile.exists()) {
System.out.println("Downloading "+url)
ant.invokeMethod("get", mapOf("src" to url, "dest" to destFile))
}
}
tasks.register("downloadLib"){
download("https://github.com/AshurAxelR/JParsedown/raw/refs/heads/master/src/com/xrbpowered/jparsedown/JParsedown.java", "src/main/java/com/xrbpowered/jparsedown/JParsedown.java")
}
tasks.withType<org.gradle.jvm.tasks.Jar>() {
exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA")
}
tasks.named("compileJava") {
dependsOn("downloadLib")
}

1
core/src/main/java/.gitignore vendored

@ -0,0 +1 @@ @@ -0,0 +1 @@
com/

14
messages/build.gradle.kts

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
description = "Umbrella : Message subsystem"
dependencies{
implementation(project(":core"))
implementation(project(":user"))
implementation("de.srsoftware:configuration.api:1.0.2")
implementation("de.srsoftware:tools.jdbc:1.3.2")
implementation("de.srsoftware:tools.mime:1.1.2")
implementation("de.srsoftware:tools.optionals:1.0.0")
implementation("de.srsoftware:tools.util:2.0.3")
implementation("org.bitbucket.b_c:jose4j:0.9.6")
implementation("org.json:json:20240303")
implementation("org.xerial:sqlite-jdbc:3.49.0.0")
}

18
messages/src/main/java/de/srsoftware/umbrella/message/Constants.java

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message;
public class Constants {
public static final String AUTH = "mail.smtp.auth";
public static final String ENVELOPE_FROM = "mail.smtp.from";
public static final String FIELD_MESSAGES = "messages";
public static final String FIELD_HOST = "host";
public static final String FIELD_PORT = "port";
public static final String HOST = "mail.smtp.host";
public static final String JSONARRAY = "json array";
public static final String JSONOBJECT = "json object";
public static final String PORT = "mail.smtp.port";
public static final String RECEIVERS = "receivers";
public static final String SSL = "mail.smtp.ssl.enable";
public static final String SUBMISSION = "submission";
}

11
messages/src/main/java/de/srsoftware/umbrella/message/MessageDb.java

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.message.model.Settings;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
public interface MessageDb {
public Settings getSettings(UmbrellaUser user) throws UmbrellaException;
public Settings update(UmbrellaUser user, Settings settings) throws UmbrellaException;
}

126
messages/src/main/java/de/srsoftware/umbrella/message/SqliteDb.java

@ -0,0 +1,126 @@ @@ -0,0 +1,126 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
import de.srsoftware.umbrella.message.model.Settings;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.stream.Collectors;
import static de.srsoftware.tools.jdbc.Condition.equal;
import static de.srsoftware.tools.jdbc.Query.*;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.message.model.Settings.Times;
import static java.lang.System.Logger.Level.ERROR;
import static java.lang.System.Logger.Level.WARNING;
import static java.text.MessageFormat.format;
public class SqliteDb implements MessageDb{
private static final System.Logger LOG = System.getLogger(SqliteDb.class.getSimpleName());
private final Connection db;
private static final String DB_VERSION = "message_db_version";
private static final int INITIAL_DB_VERSION = 1;
private static final String TABLE_SUBMISSIONS = "message_submission";
public SqliteDb(Connection conn){
db = conn;
init();
}
private void createSubmissionTable() {
var createTable = """
CREATE TABLE IF NOT EXISTS {0} ( {1} Integer PRIMARY KEY, {2} VARCHAR(255) NOT NULL);
""";
try {
var stmt = db.prepareStatement(format(createTable,TABLE_SUBMISSIONS, USER_ID, VALUE));
stmt.execute();
stmt.close();
} catch (SQLException e) {
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_SUBMISSIONS,e);
throw new RuntimeException(e);
}
}
private int createSettingsTable() {
var createTable = """
CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255) NOT NULL);
""";
try {
var stmt = db.prepareStatement(format(createTable,TABLE_SETTINGS, KEY, VALUE));
stmt.execute();
stmt.close();
} catch (SQLException e) {
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_SETTINGS,e);
throw new RuntimeException(e);
}
Integer version = null;
try {
var rs = select(VALUE).from(TABLE_SETTINGS).where(KEY, equal(DB_VERSION)).exec(db);
if (rs.next()) version = rs.getInt(VALUE);
rs.close();
if (version == null) {
version = INITIAL_DB_VERSION;
insertInto(TABLE_SETTINGS, KEY, VALUE).values(DB_VERSION,version).execute(db).close();
}
return version;
} catch (SQLException e) {
LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,DB_VERSION,TABLE_SETTINGS,e);
throw new RuntimeException(e);
}
}
private int createTables() {
createSubmissionTable();
return createSettingsTable();
}
@Override
public Settings getSettings(UmbrellaUser user) throws UmbrellaException {
try {
Settings settings = null;
var rs = select(VALUE).from(TABLE_SUBMISSIONS).where(USER_ID,equal(user.id())).exec(db);
if (rs.next()) settings = toSettings(rs);
rs.close();
if (settings != null) return settings;
throw new UmbrellaException(500,"No submission settings stored for {0}",user);
} catch (SQLException e) {
LOG.log(ERROR,"Failed to read settings for {0} from {1} table",user,TABLE_SUBMISSIONS,e);
throw new UmbrellaException(500,"Failed to read settings for {0} from {1} table",user,TABLE_SUBMISSIONS).causedBy(e);
}
}
private void init() {
var version = createTables();
}
private Settings toSettings(ResultSet rs) throws SQLException {
var submission = rs.getString(VALUE);
var parts = submission.split(",");
var times = new HashSet<Times>();
for (var part : parts) try {
times.add(Times.valueOf(part));
} catch (IllegalArgumentException e) {
LOG.log(WARNING,"encountered {0}, which is not a valid Times enumeration value!",part);
}
return new Settings(times);
}
@Override
public Settings update(UmbrellaUser user, Settings settings) throws UmbrellaException {
var times = settings.times().stream().map(Times::toString).collect(Collectors.joining(","));
try {
replaceInto(TABLE_SUBMISSIONS, USER_ID, VALUE).values(user.id(),times).execute(db).close();
return settings;
} catch (SQLException e) {
LOG.log(WARNING,"Failed to store submission data for {0}!",user.name(),e);
throw new UmbrellaException(500,"Failed to store submission data for {0}!",user.name()).causedBy(e);
}
}
}

37
messages/src/main/java/de/srsoftware/umbrella/message/model/Attachment.java

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import static de.srsoftware.umbrella.core.Constants.*;
import de.srsoftware.umbrella.core.UmbrellaException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Objects;
import java.util.Set;
import org.json.JSONObject;
public record Attachment(String name, String mime, byte[] content) {
private static final Base64.Decoder BASE64 = Base64.getDecoder();
public static <T> Attachment of(JSONObject json) throws UmbrellaException {
for (var key : Set.of(NAME, MIME, DATA)) {
if (!json.has(key)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,key);
}
if (!(json.get(NAME) instanceof String name)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, NAME,STRING);
if (!(json.get(MIME) instanceof String mime)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, MIME,STRING);
if (!(json.get(DATA) instanceof String data)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, DATA,STRING);
return new Attachment(name,mime, BASE64.decode(data));
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Attachment that)) return false;
return Objects.equals(name, that.name) && Objects.equals(mime, that.mime) && Objects.deepEquals(content, that.content);
}
@Override
public int hashCode() {
return Objects.hash(name, mime, Arrays.hashCode(content));
}
}

70
messages/src/main/java/de/srsoftware/umbrella/message/model/CombinedMessage.java

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import static java.lang.System.Logger.Level.DEBUG;
import static java.lang.System.Logger.Level.TRACE;
import static java.text.MessageFormat.format;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
public class CombinedMessage {
private static final System.Logger LOG = System.getLogger(CombinedMessage.class.getSimpleName());
private final Set<Attachment> attachments = new HashSet<>();
private final StringBuilder body = new StringBuilder();
private final List<Message> mergedMessages = new ArrayList<>();
private final Function<String, String> translate;
private UmbrellaUser sender = null;
private String subject = null;
public CombinedMessage(Function<String,String> translate){
LOG.log(DEBUG,"Creating combined message…");
this.translate = translate;
}
public void merge(Message message) {
LOG.log(TRACE,"Merging {0} into combined message…",message);
switch (mergedMessages.size()){
case 0:
body.append(message.body());
sender = message.sender();
subject = message.subject();
break;
case 1:
body.insert(0,format("# {0}:\n# {1}:\n\n",sender,subject)); // insert sender and subject of first message right before the body of the first message
subject = translate.apply("Collected messages");
// no break here, we need to append the subject and content
default:
body.append("\n\n# ").append(message.sender()).append(":\n");
body.append("# ").append(message.subject()).append(":\n\n");
body.append(message.body());
}
attachments.addAll(message.attachments());
mergedMessages.add(message);
}
public Set<Attachment> attachments() {
return attachments;
}
public String body() {
return body.toString();
}
public UmbrellaUser sender() {
return sender;
}
public String subject() {
return subject;
}
public List<Message> messages() {
return mergedMessages;
}
}

57
messages/src/main/java/de/srsoftware/umbrella/message/model/Envelope.java

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import static de.srsoftware.umbrella.core.Constants.ERROR_INVALID_FIELD;
import static de.srsoftware.umbrella.core.Constants.ERROR_MISSING_FIELD;
import static de.srsoftware.umbrella.message.Constants.*;
import static java.text.MessageFormat.format;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.user.model.User;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.json.JSONArray;
import org.json.JSONObject;
public class Envelope {
private Message message;
private Set<User> receivers;
public Envelope(Message message, HashSet<User> receivers) {
this.message = message;
this.receivers = receivers;
}
public static Envelope from(JSONObject json) throws UmbrellaException {
if (!json.has(RECEIVERS)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,RECEIVERS);
var message = Message.from(json);
var obj = json.get(RECEIVERS);
if (obj instanceof JSONObject) obj = new JSONArray(List.of(obj));
if (!(obj instanceof JSONArray receiverList)) throw new UmbrellaException(400,ERROR_INVALID_FIELD,RECEIVERS,JSONARRAY);
var receivers = new HashSet<User>();
for (var o : receiverList){
if (!(o instanceof JSONObject receiverData)) throw new UmbrellaException(400,ERROR_INVALID_FIELD,"entries of "+RECEIVERS,JSONOBJECT);
receivers.add(User.of(receiverData));
}
return new Envelope(message,receivers);
}
public boolean isFor(User receiver) {
return receivers.contains(receiver);
}
public Message message(){
return message;
}
public Set<User> receivers(){
return receivers;
}
@Override
public String toString() {
return format("{0} (to: {1}), subject: {2}",getClass().getSimpleName(),receivers.stream().map(User::email).collect(Collectors.joining(", ")),message.subject());
}
}

53
messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import de.srsoftware.umbrella.user.model.User;
import java.util.*;
/**
* This maps recipient email addresses to the pending messages of that recipient
*/
public class MailQueue extends ArrayList<Envelope>{
private record Receiver(User user, Message message){}
private final HashMap<Receiver,List<Exception>> exceptions = new HashMap<>();
public interface Listener{
public void messagesAdded();
public void setQueue(MailQueue queue);
}
private final Set<Listener> listeners = new HashSet<>();
public void addListener(Listener listener) {
listeners.add(listener);
listener.setQueue(this);
}
public void commit() {
listeners.forEach(Listener::messagesAdded);
}
public List<Envelope> envelopesFor(User recv) {
return stream().filter(env -> env.isFor(recv)).toList();
}
public void failedAt(User receiver, CombinedMessage combined, Exception ex) {
for (var message : combined.messages()) exceptions.computeIfAbsent(new Receiver(receiver,message), k -> new ArrayList<>()).add(ex);
}
/**
* return the email addresses of the recipients of all messages in the queue
*
* @return a list of email addresses
*/
public List<User> receivers() {
return stream().map(Envelope::receivers)
.flatMap(Set::stream)
.filter(Objects::nonNull)
.distinct()
.toList();
}
}

60
messages/src/main/java/de/srsoftware/umbrella/message/model/Message.java

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import static de.srsoftware.tools.Optionals.isSet;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.message.Constants.*;
import static java.text.MessageFormat.format;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
import de.srsoftware.umbrella.user.model.User;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.json.JSONArray;
import org.json.JSONObject;
public record Message(UmbrellaUser sender, String subject, String body, List<Attachment> attachments) {
public static Message from(JSONObject json) throws UmbrellaException {
for (var key : Set.of(SENDER, SUBJECT, BODY)) {
if (!json.has(key)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,key);
}
if (!(json.get(SENDER) instanceof JSONObject senderObject)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, SENDER,JSONOBJECT);
if (!(json.get(SUBJECT) instanceof String subject && isSet(subject))) throw new UmbrellaException(400,ERROR_INVALID_FIELD, SUBJECT,STRING);
if (!(json.get(BODY) instanceof String body && isSet(body))) throw new UmbrellaException(400,ERROR_INVALID_FIELD, BODY,STRING);
var user = User.of(senderObject);
if (!(user instanceof UmbrellaUser sender)) throw new UmbrellaException(400,"Sender is not an umbrella user!");
var attachments = new ArrayList<Attachment>();
if (json.has(ATTACHMENTS)){
var jsonAttachments = json.get(ATTACHMENTS);
if (jsonAttachments instanceof JSONObject obj) jsonAttachments = new JSONArray(List.of(obj));
if (jsonAttachments instanceof JSONArray arr){
for (var att : arr){
if (!(att instanceof JSONObject o)) throw new UmbrellaException(400,"Attachments contains entry that is not an object: {}",att);
var attachment = Attachment.of(o);
attachments.add(attachment);
}
}
}
return new Message(sender,subject,body,attachments);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Message message)) return false;
return Objects.equals(sender, message.sender) && Objects.equals(subject, message.subject) && Objects.equals(body, message.body) && Objects.equals(attachments, message.attachments);
}
@Override
public int hashCode() {
return Objects.hash(subject, body, attachments);
}
@Override
public String toString() {
return format("{0}(from: {1}), subject: {2}",getClass().getSimpleName(),sender,subject);
}
}

38
messages/src/main/java/de/srsoftware/umbrella/message/model/Settings.java

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message.model;
import static de.srsoftware.umbrella.message.Constants.SUBMISSION;
import de.srsoftware.tools.Mappable;
import java.util.Map;
import java.util.Set;
public record Settings(Set<Times> times) implements Mappable {
public enum Times{
INSTANTLY,
AT8,
AT10,
AT12,
AT14,
AT16,
AT18,
AT20;
public boolean matches(int hour){
if (this == INSTANTLY) return false;
return Integer.parseInt(toString().substring(2)) == hour;
}
}
public boolean sendAt(Integer scheduledHour) {
return times.contains(Times.INSTANTLY) || (scheduledHour != null && times.stream().anyMatch(time -> time.matches(scheduledHour)));
}
@Override
public Map<String, Object> toMap() {
return Map.of(SUBMISSION,times);
}
}

6
settings.gradle.kts

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
rootProject.name = "Umbrella25"
include("backend")
include("core")
include("legacy")
include("messages")
include("translations")
include("user")
include("web")
include("core")
include("legacy")

19
user/src/main/java/de/srsoftware/umbrella/user/model/User.java

@ -2,9 +2,12 @@ @@ -2,9 +2,12 @@
package de.srsoftware.umbrella.user.model;
import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Constants.LANGUAGE;
import static java.text.MessageFormat.format;
import java.util.Objects;
import org.json.JSONObject;
public class User {
@ -37,4 +40,20 @@ public class User { @@ -37,4 +40,20 @@ public class User {
public String toString() {
return format("{1}({0})", nullable(name()).orElse(email()),getClass().getSimpleName());
}
public static User of(JSONObject json){
if (json.has(USER)) json = json.getJSONObject(USER);
var name = json.has(NAME) ? json.getString(NAME) : null;
var email = json.has(EMAIL) ? json.getString(EMAIL) : null;
if (json.has(ID) && json.has(THEME)) {
return new UmbrellaUser(
json.getLong(ID),
name,
email,
json.getString(THEME),
json.has(LANGUAGE) ? json.getString(LANGUAGE) : null
);
}
return new User(name,email);
}
}

Loading…
Cancel
Save