14 changed files with 538 additions and 6 deletions
@ -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") |
||||
} |
||||
@ -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"; |
||||
|
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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)); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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()); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
|
||||
} |
||||
@ -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") |
||||
Loading…
Reference in new issue