diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java
index fbfaeee..c5412bb 100644
--- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java
+++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java
@@ -53,13 +53,16 @@ public class Application {
var userDb = new SqliteDB(connectionProvider.get(userDbFile));
var loginServicedb = new SqliteDB(connectionProvider.get(loginDbFile));
- var messageSystem = new MessageSystem(messageDb);
+ var translationModule = new Translations();
+
+ var messageSystem = new MessageSystem(messageDb,translationModule,config.subset("umbrella.modules.message").orElseThrow());
var server = HttpServer.create(new InetSocketAddress(port), 0);
server.setExecutor(Executors.newFixedThreadPool(threads));
+
new LegacyApi(userDb,config).bindPath("/legacy").on(server);
new MessageApi(messageSystem).bindPath("/api/messages").on(server);
- new Translations().bindPath("/api/translations").on(server);
+ translationModule.bindPath("/api/translations").on(server);
new UserModule(userDb,loginServicedb).bindPath("/api/user").on(server);
new WebHandler().bindPath("/").on(server);
server.start();
diff --git a/core/src/main/java/de/srsoftware/umbrella/core/api/Translator.java b/core/src/main/java/de/srsoftware/umbrella/core/api/Translator.java
new file mode 100644
index 0000000..a1b0d04
--- /dev/null
+++ b/core/src/main/java/de/srsoftware/umbrella/core/api/Translator.java
@@ -0,0 +1,6 @@
+/* © SRSoftware 2025 */
+package de.srsoftware.umbrella.core.api;
+
+public interface Translator {
+ public String translate(String language, String text);
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index dff3bac..740960c 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -9,6 +9,7 @@
import Login from "./Components/Login.svelte";
import Messages from "./routes/message/Messages.svelte";
import Menu from "./Components/Menu.svelte";
+ import ResetPw from "./routes/user/ResetPw.svelte";
import Search from "./routes/search/Search.svelte";
import User from "./routes/user/User.svelte";
import UserEdit from "./routes/user/Edit.svelte";
@@ -42,8 +43,11 @@
{:else}
-
+
+
+
+
{/if}
diff --git a/frontend/src/Components/Login.svelte b/frontend/src/Components/Login.svelte
index db333fe..9182182 100644
--- a/frontend/src/Components/Login.svelte
+++ b/frontend/src/Components/Login.svelte
@@ -2,11 +2,14 @@
import { onMount } from 'svelte';
import { t } from '../translations.svelte.js';
import { checkUser, tryLogin } from '../user.svelte.js';
+ import { useTinyRouter } from 'svelte-tiny-router';
let credentials = { username : null, password : null }
let services = $state([]);
+ const router = useTinyRouter();
function doLogin(ev){
+ ev.preventDefault();
tryLogin(credentials);
}
@@ -36,6 +39,11 @@
}
}
}
+
+ function resetPW(ev){
+ ev.preventDefault();
+ router.navigate('/user/reset/pw');
+ }
-
diff --git a/frontend/src/Components/Menu.svelte b/frontend/src/Components/Menu.svelte
index 7982c78..248b6ee 100644
--- a/frontend/src/Components/Menu.svelte
+++ b/frontend/src/Components/Menu.svelte
@@ -13,7 +13,7 @@ async function fetchModules(){
const resp = await fetch(url,{credentials:'include'});
if (resp.ok){
const arr = await resp.json();
- for (let entry of arr) modules.push({name:t('module.'+entry.module),url:entry.url});
+ for (let entry of arr) modules.push({name:t('menu.'+entry.module),url:entry.url});
console.log(modules);
} else {
console.log('error');
diff --git a/frontend/src/routes/user/ResetPw.svelte b/frontend/src/routes/user/ResetPw.svelte
new file mode 100644
index 0000000..0c5fc30
--- /dev/null
+++ b/frontend/src/routes/user/ResetPw.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/messages/build.gradle.kts b/messages/build.gradle.kts
index df574cc..5853338 100644
--- a/messages/build.gradle.kts
+++ b/messages/build.gradle.kts
@@ -3,6 +3,7 @@ description = "Umbrella : Message subsystem"
dependencies{
implementation(project(":core"))
implementation(project(":user"))
+ implementation("com.sun.mail:jakarta.mail:2.0.1")
implementation("de.srsoftware:configuration.api:1.0.2")
implementation("de.srsoftware:tools.jdbc:1.3.2")
implementation("de.srsoftware:tools.mime:1.1.2")
diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java b/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java
index b58b181..0abc3c1 100644
--- a/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java
+++ b/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java
@@ -3,6 +3,7 @@ package de.srsoftware.umbrella.message;
public class Constants {
public static final String AUTH = "mail.smtp.auth";
+ public static final String DEBUG_ADDREESS = "debug_addres";
public static final String ENVELOPE_FROM = "mail.smtp.from";
public static final String FIELD_MESSAGES = "messages";
public static final String FIELD_HOST = "host";
@@ -12,6 +13,7 @@ public class Constants {
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 SMTP = "smtp";
public static final String SSL = "mail.smtp.ssl.enable";
public static final String SUBMISSION = "submission";
diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/MessageSystem.java b/messages/src/main/java/de/srsoftware/umbrella/message/MessageSystem.java
index 6aab39b..35863ce 100644
--- a/messages/src/main/java/de/srsoftware/umbrella/message/MessageSystem.java
+++ b/messages/src/main/java/de/srsoftware/umbrella/message/MessageSystem.java
@@ -1,10 +1,195 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.message;
-public class MessageSystem {
+import static de.srsoftware.tools.PathHandler.CONTENT_TYPE;
+import static de.srsoftware.umbrella.core.Constants.*;
+import static de.srsoftware.umbrella.message.Constants.*;
+import static de.srsoftware.umbrella.user.Constants.PASS;
+import static java.lang.System.Logger.Level.*;
+
+import de.srsoftware.configuration.Configuration;
+import de.srsoftware.umbrella.core.UmbrellaException;
+import de.srsoftware.umbrella.core.api.Translator;
+import de.srsoftware.umbrella.message.model.CombinedMessage;
+import de.srsoftware.umbrella.message.model.Envelope;
+import de.srsoftware.umbrella.message.model.PostBox;
+import de.srsoftware.umbrella.user.model.UmbrellaUser;
+import de.srsoftware.umbrella.user.model.User;
+import jakarta.activation.DataHandler;
+import jakarta.mail.Message;
+import jakarta.mail.MessagingException;
+import jakarta.mail.Session;
+import jakarta.mail.Transport;
+import jakarta.mail.internet.MimeBodyPart;
+import jakarta.mail.internet.MimeMessage;
+import jakarta.mail.internet.MimeMultipart;
+import jakarta.mail.util.ByteArrayDataSource;
+import java.util.*;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.Function;
+
+public class MessageSystem implements PostBox {
+ public static final System.Logger LOG = System.getLogger(MessageSystem.class.getSimpleName());
+ private final Timer timer = new Timer();
+ private record Receiver(User user, de.srsoftware.umbrella.message.model.Message message){}
+
+ private class SubmissionTask extends TimerTask{
+
+
+ private final Integer hour;
+
+ public SubmissionTask(Integer hour) {
+ this.hour = hour;
+ }
+
+ @Override
+ public void run() {
+ processMessages(hour);
+ }
+
+ public void schedule() {
+ var date = Calendar.getInstance();
+ date.set(Calendar.HOUR_OF_DAY, hour);
+ date.set(Calendar.MINUTE, 0);
+ date.set(Calendar.SECOND, 0);
+ date.set(Calendar.MILLISECOND,0);
+ if (date.before(Calendar.getInstance())) date.add(Calendar.HOUR, 24);
+ timer.schedule(this,date.getTime());
+ LOG.log(INFO,"Scheduled {0} at {1}",getClass().getSimpleName(),date.getTime());
+ }
+
+
+
+ }
+ private final String from,host,user,pass;
+ private final int port;
private final SqliteMessageDb db;
+ private Session session;
+ private List queue = new CopyOnWriteArrayList<>();
+ private String debugAddress;
+ private final HashMap> exceptions = new HashMap<>();
+ private final Translator translator;
- public MessageSystem(SqliteMessageDb messageDb) {
+ public MessageSystem(SqliteMessageDb messageDb, Translator translator, Configuration config) {
db = messageDb;
+ this.translator = translator;
+ debugAddress = config.get(DEBUG_ADDREESS).map(Object::toString).orElse(null);
+ config = config.subset(SMTP).orElseThrow(() -> new RuntimeException("umbrella.modules.message.smtp not configured!"));
+ port = config.get(FIELD_PORT,587);
+ host = config.get(FIELD_HOST).map(Object::toString).orElseThrow(() -> new RuntimeException("umbrella.modules.message.smtp.host not configured!"));
+ user = config.get(USER).map(Object::toString).orElseThrow(() -> new RuntimeException("umbrella.modules.message.smtp.user not configured!"));
+ pass = config.get(PASS).map(Object::toString).orElseThrow(() -> new RuntimeException("umbrella.modules.message.smtp.pass not configured!"));
+ from = user;
+ new SubmissionTask(8).schedule();
+ new SubmissionTask(10).schedule();
+ new SubmissionTask(12).schedule();
+ new SubmissionTask(14).schedule();
+ new SubmissionTask(16).schedule();
+ new SubmissionTask(18).schedule();
+ new SubmissionTask(20).schedule();
+ }
+
+ @Override
+ public void send(Envelope envelope) {
+ queue.add(envelope);
+ new Thread(() -> processMessages(null)).start();
+ }
+
+ private synchronized void processMessages(Integer scheduledHour) {
+ LOG.log(INFO,"Running {0}…",scheduledHour == null ? "instantly" : "scheduled at "+scheduledHour);
+ var queue = new ArrayList<>(this.queue);
+ var dueRecipients = new ArrayList();
+ List recipients = queue.stream().map(Envelope::receivers).flatMap(Set::stream).filter(Objects::nonNull).distinct().toList();
+
+ { // for known users: get notification preferences, fallback to _immediately_ for unknown users
+ for (User recv : recipients) {
+ if (recv instanceof UmbrellaUser uu) {
+ try {
+ if (!db.getSettings(uu).sendAt(scheduledHour)) continue;
+ } catch (UmbrellaException ignored) {}
+ }
+ dueRecipients.add(recv);
+ }
+ }
+
+ var date = new Date();
+
+ for (var receiver : dueRecipients){
+ Function translateFunction = receiver instanceof UmbrellaUser uu ? text -> translator.translate(uu.language(),text) : text -> text;
+ // combine messages for user
+ var combined = new CombinedMessage(translateFunction);
+ var envelopes = queue.stream().filter(env -> env.isFor(receiver)).toList();
+ for (var envelope : envelopes) combined.merge(envelope.message());
+
+ try {
+ send(combined,receiver,date);
+ for (var envelope : envelopes){
+ var audience = envelope.receivers();
+ audience.remove(receiver);
+ if (audience.isEmpty()) queue.remove(envelope);
+ }
+ } catch (Exception ex){
+ LOG.log(WARNING,"Failed to deliver mail ({0}) to {1}.",combined.subject(),receiver,ex);
+ for (var message : combined.messages()) exceptions.computeIfAbsent(new Receiver(receiver,message), k -> new ArrayList<>()).add(ex);
+
+ }
+ }
+
+ if (scheduledHour != null) new SubmissionTask(scheduledHour).schedule();
+ }
+
+ private Session session() {
+ if (session == null){
+ Properties props = new Properties();
+ props.put(HOST, host);
+ props.put(PORT, port);
+ props.put(AUTH, true);
+ props.put(SSL, true);
+ props.put(ENVELOPE_FROM,from);
+ session = Session.getInstance(props);
+ }
+ return session;
+ }
+
+ public void setDebugAddress(String newVal) {
+ this.debugAddress = newVal;
+ }
+
+
+ private void send(CombinedMessage message, User receiver, Date date) throws MessagingException {
+ LOG.log(TRACE,"Sending combined message to {0}…",receiver);
+ session = session();
+ MimeMessage msg = new MimeMessage(session);
+ msg.addHeader(CONTENT_TYPE, "text/markdown; charset=UTF-8");
+ msg.addHeader("format", "flowed");
+ msg.addHeader("Content-Transfer-Encoding", "8bit");
+ msg.setFrom(message.sender().email());
+ msg.setSubject(message.subject(), UTF8);
+ msg.setSentDate(date);
+ var toEmail = debugAddress != null ? debugAddress : receiver.email();
+ msg.setRecipients(Message.RecipientType.TO, toEmail);
+
+ if (message.attachments().isEmpty()){
+ msg.setText(message.body(), UTF8);
+ } else {
+ var multipart = new MimeMultipart();
+ var body = new MimeBodyPart();
+ body.setText(message.body(),UTF8);
+
+ multipart.addBodyPart(body);
+ for (var attachment : message.attachments()){
+ var part = new MimeBodyPart();
+ part.setDataHandler(new DataHandler(new ByteArrayDataSource(attachment.content(),attachment.mime())));
+ part.setFileName(attachment.name());
+ multipart.addBodyPart(part);
+ msg.setContent(multipart);
+ }
+ }
+
+
+
+ LOG.log(TRACE, "Message to {0} is ready…", receiver);
+ Transport.send(msg,user,pass);
+ LOG.log(DEBUG, "Sent message to {0}.", receiver);
}
}
diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java
deleted file mode 100644
index 6fc119c..0000000
--- a/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java
+++ /dev/null
@@ -1,53 +0,0 @@
-/* © 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{
-
- private record Receiver(User user, Message message){}
-
- private final HashMap> exceptions = new HashMap<>();
-
- public interface Listener{
- public void messagesAdded();
- public void setQueue(MailQueue queue);
- }
-
- private final Set listeners = new HashSet<>();
-
- public void addListener(Listener listener) {
- listeners.add(listener);
- listener.setQueue(this);
- }
-
- public void commit() {
- listeners.forEach(Listener::messagesAdded);
- }
-
- public List 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 receivers() {
- return stream().map(Envelope::receivers)
- .flatMap(Set::stream)
- .filter(Objects::nonNull)
- .distinct()
- .toList();
- }
-}
diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/PostBox.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/PostBox.java
new file mode 100644
index 0000000..57c398b
--- /dev/null
+++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/PostBox.java
@@ -0,0 +1,6 @@
+/* © SRSoftware 2025 */
+package de.srsoftware.umbrella.message.model;
+
+public interface PostBox {
+ public void send(Envelope envelope);
+}
diff --git a/messages/src/test/java/TriggerTest.java b/messages/src/test/java/TriggerTest.java
new file mode 100644
index 0000000..479cd29
--- /dev/null
+++ b/messages/src/test/java/TriggerTest.java
@@ -0,0 +1,30 @@
+/* © SRSoftware 2025 */
+import static java.lang.Thread.sleep;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class TriggerTest {
+
+
+
+ @Test
+ public void testSynchronization() throws InterruptedException {
+ StringBuffer sb = new StringBuffer();
+ new Thread(() -> writeTo(sb,"A")).start();
+ sleep(100);
+ new Thread(() -> writeTo(sb,"B")).start();
+ sleep(100);
+ new Thread(() -> writeTo(sb,"C")).start();
+ sleep(1000);
+ assertEquals("AABBCC",sb.toString());
+ }
+
+ private synchronized void writeTo(StringBuffer sb, String mark) {
+ sb.append(mark);
+ try {
+ sleep(200);
+ } catch (InterruptedException ignored) {}
+ sb.append(mark);
+ }
+}
diff --git a/translations/build.gradle.kts b/translations/build.gradle.kts
index cb99751..5217277 100644
--- a/translations/build.gradle.kts
+++ b/translations/build.gradle.kts
@@ -1,5 +1,6 @@
description = "Umbrella : Translations"
dependencies{
+ implementation(project(":core"))
implementation("org.json:json:20240303")
}
\ No newline at end of file
diff --git a/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java b/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java
index 8aa47ce..caab7e8 100644
--- a/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java
+++ b/translations/src/main/java/de/srsoftware/umbrella/translations/Translations.java
@@ -7,13 +7,14 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.tools.Path;
import de.srsoftware.tools.PathHandler;
+import de.srsoftware.umbrella.core.api.Translator;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.util.HashMap;
import org.json.JSONObject;
-public class Translations extends PathHandler {
+public class Translations extends PathHandler implements Translator {
private static final System.Logger LOG = System.getLogger("Translations");
private HashMap translations = new HashMap<>();
@@ -23,10 +24,13 @@ public class Translations extends PathHandler {
allowOrigin(ex,"*");
if (path.empty())return sendContent(ex,501,"Language missing");
var lang = path.pop();
- var translationsForLang = translations.get(lang);
- if (translationsForLang == null) translations.put(lang,translationsForLang = loadTranslations(lang));
+ return sendContent(ex,getTranslations(lang));
+ }
- return sendContent(ex,translationsForLang);
+ private JSONObject getTranslations(String lang) throws IOException {
+ var result = translations.get(lang);
+ if (result == null) translations.put(lang,result = loadTranslations(lang));
+ return result;
}
private JSONObject loadTranslations(String lang) throws IOException {
@@ -44,4 +48,14 @@ public class Translations extends PathHandler {
return new JSONObject();
}
}
+
+ @Override
+ public String translate(String language, String text) {
+ try {
+ var translations = getTranslations(language);
+ return translations.has(text) ? translations.getString(text) : text;
+ } catch (IOException e) {
+ return text;
+ }
+ }
}
diff --git a/translations/src/main/resources/de.json b/translations/src/main/resources/de.json
index ac6db24..fe62cd7 100644
--- a/translations/src/main/resources/de.json
+++ b/translations/src/main/resources/de.json
@@ -8,14 +8,30 @@
"login" : {
"do_login" : "anmelden",
"Email_or_Username": "Email oder Nutzername",
+ "forgot_pass" : "Password vergessen?",
"Login" : "Anmeldung",
"OIDC_Login" : "Anmeldung mit OIDC",
"Password" : "Passwort"
},
"menu" : {
+ "bookmark": "Lesezeichen",
+ "company": "Firma",
+ "contact": "Kontakte",
+ "document": "Dokumente",
+ "files": "Dateien",
+ "items": "Items",
"logout": "Abmelden",
+ "message": "Benachrichtigungen",
+ "model": "Modelle",
+ "notes": "Notizen",
+ "project": "Projekte",
+ "stock": "Inventar",
+ "task": "Aufgaben",
+ "time": "Zeiterfassung",
+ "tutorial": "Tutorial",
+ "user": "Benutzer",
"users": "Benutzer",
- "tutorial": "Tutorial"
+ "wiki": "Wiki"
},
"status" : {
"403": "Zugriff verweigert",
@@ -64,10 +80,13 @@
"saved": "gespeichert",
"save_service": "Service speichern",
"save_user": "Nutzer speichern",
+ "sent_email": "Email gesendet",
"service": "Service",
+ "settings" : "Eisntellungen",
"theme": "Design",
"unlink": "Trennen",
"update": "aktualisieren",
+ "UPDATE_USERS" : "Nutzer aktualisieren",
"user_module" : "Umbrella User-Verwaltung",
"your_profile": "dein Profil"
}