KB68 Implement newsletter sending.
Change-Id: I1d56e40d7f35d6be77fde1a1e8519a91bd2dc3b8
diff --git a/pom.xml b/pom.xml
index d0c524e..ab3f5ac 100644
--- a/pom.xml
+++ b/pom.xml
@@ -142,6 +142,10 @@
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
+ <artifactId>quarkus-mailer</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
@@ -158,6 +162,10 @@
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
+ <artifactId>quarkus-scheduler</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java
new file mode 100644
index 0000000..3d9a3fe
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java
@@ -0,0 +1,29 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import eu.mulk.mulkcms2.benki.posts.Post;
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import java.time.OffsetDateTime;
+import java.util.Collection;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.FetchType;
+import javax.persistence.Id;
+import javax.persistence.OneToMany;
+import javax.persistence.OrderBy;
+import javax.persistence.Table;
+
+@Entity
+@Table(name = "newsletters", schema = "benki")
+public class Newsletter extends PanacheEntityBase {
+
+ @Id
+ @Column(name = "id", nullable = false)
+ public Integer id;
+
+ @Column(name = "date", nullable = false)
+ public OffsetDateTime date = OffsetDateTime.now();
+
+ @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
+ @OrderBy("date")
+ public Collection<Post<?>> posts;
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
new file mode 100644
index 0000000..1f13c08
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java
@@ -0,0 +1,117 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import static java.util.stream.Collectors.partitioningBy;
+import static java.util.stream.Collectors.toList;
+
+import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
+import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
+import eu.mulk.mulkcms2.benki.posts.Post;
+import io.quarkus.mailer.MailTemplate.MailTemplateInstance;
+import io.quarkus.panache.common.Sort;
+import io.quarkus.qute.TemplateExtension;
+import io.quarkus.qute.api.CheckedTemplate;
+import io.quarkus.scheduler.Scheduled;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.FormatStyle;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.CheckForNull;
+import javax.enterprise.context.Dependent;
+import javax.persistence.EntityManager;
+import javax.persistence.PersistenceContext;
+import javax.transaction.Transactional;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+import org.hibernate.Session;
+
+@Dependent
+public class NewsletterSender {
+
+ private static final DateTimeFormatter humanDateFormatter =
+ DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG);
+
+ @ConfigProperty(name = "mulkcms.newsletter.time-zone")
+ ZoneId newsletterTimeZone;
+
+ @PersistenceContext EntityManager em;
+
+ @CheckedTemplate
+ static class Templates {
+ public static native MailTemplateInstance newsletter(
+ Newsletter newsletter, List<Bookmark> bookmarks, List<LazychatMessage> lazychatMessages);
+ }
+
+ @Scheduled(cron = "0 0 0 ? * Mon")
+ @Transactional
+ void run() throws InterruptedException, TimeoutException, ExecutionException {
+ var session = em.unwrap(Session.class);
+
+ List<Post<?>> posts = Post.list("newsletter IS NULL", Sort.ascending("date"));
+ Post.fetchTexts(posts);
+
+ if (posts.isEmpty()) {
+ return;
+ }
+
+ var postsByClass = posts.stream().collect(partitioningBy(Post::isBookmark));
+ var bookmarks =
+ postsByClass.getOrDefault(Boolean.TRUE, List.of()).stream()
+ .map(x -> (Bookmark) x)
+ .collect(toList());
+ var lazychatMessages =
+ postsByClass.getOrDefault(Boolean.FALSE, List.of()).stream()
+ .map(x -> (LazychatMessage) x)
+ .collect(toList());
+
+ var date = OffsetDateTime.now(newsletterTimeZone);
+ var newsletterNumber =
+ (int)
+ session
+ .createQuery("SELECT max(id) FROM Newsletter", Integer.class)
+ .uniqueResultOptional()
+ .map(x -> x + 1)
+ .orElse(1);
+
+ var newsletter = new Newsletter();
+ newsletter.id = newsletterNumber;
+ newsletter.date = date;
+ newsletter.persist();
+
+ posts.forEach(post -> post.newsletter = newsletter);
+
+ var subscriberEmails =
+ NewsletterSubscription.<NewsletterSubscription>streamAll()
+ .map(x -> x.email)
+ .toArray(String[]::new);
+
+ var mailText = Templates.newsletter(newsletter, bookmarks, lazychatMessages);
+ var sendJob =
+ mailText
+ .subject(String.format("MulkCMS newsletter #%d", newsletterNumber))
+ .bcc(subscriberEmails)
+ .send();
+ sendJob.toCompletableFuture().get(10000, TimeUnit.SECONDS);
+ }
+
+ @TemplateExtension
+ @CheckForNull
+ static String humanDate(@CheckForNull LocalDate x) {
+ if (x == null) {
+ return null;
+ }
+ return humanDateFormatter.format(x);
+ }
+
+ @TemplateExtension
+ @CheckForNull
+ static String humanDate(@CheckForNull OffsetDateTime x) {
+ if (x == null) {
+ return null;
+ }
+ return humanDateFormatter.format(x);
+ }
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
new file mode 100644
index 0000000..7aeda60
--- /dev/null
+++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java
@@ -0,0 +1,29 @@
+package eu.mulk.mulkcms2.benki.newsletter;
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
+import java.time.OffsetDateTime;
+import javax.persistence.Column;
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.Id;
+import javax.persistence.Table;
+import javax.validation.constraints.Email;
+import org.hibernate.annotations.NaturalId;
+
+@Entity
+@Table(name = "newsletter_subscriptions", schema = "benki")
+public class NewsletterSubscription extends PanacheEntityBase {
+
+ @Id
+ @Column(name = "id", nullable = false)
+ @GeneratedValue
+ public Integer id;
+
+ @Column(name = "start_date", nullable = false)
+ public OffsetDateTime startDate = OffsetDateTime.now();
+
+ @NaturalId
+ @Column(name = "email", nullable = false)
+ @Email
+ public String email;
+}
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
index fd023d7..346b71f 100644
--- a/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
+++ b/src/main/java/eu/mulk/mulkcms2/benki/posts/Post.java
@@ -5,12 +5,14 @@
import eu.mulk.mulkcms2.benki.accesscontrol.Role;
import eu.mulk.mulkcms2.benki.bookmarks.Bookmark;
import eu.mulk.mulkcms2.benki.lazychat.LazychatMessage;
+import eu.mulk.mulkcms2.benki.newsletter.Newsletter;
import eu.mulk.mulkcms2.benki.users.User;
import eu.mulk.mulkcms2.benki.users.User_;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
@@ -70,6 +72,12 @@
public OffsetDateTime date;
@ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "newsletter", referencedColumnName = "id", nullable = true)
+ @CheckForNull
+ @JsonbTransient
+ public Newsletter newsletter;
+
+ @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner", referencedColumnName = "id")
@CheckForNull
@JsonbTransient
@@ -340,18 +348,22 @@
}
// Fetch texts (to avoid n+1 selects).
- var postIds = forwardResults.stream().map(x -> x.id).collect(toList());
+ fetchTexts(forwardResults);
+
+ return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
+ }
+
+ public static <T extends Post<?>> void fetchTexts(Collection<T> posts) {
+ var postIds = posts.stream().map(x -> x.id).collect(toList());
if (!postIds.isEmpty()) {
find("SELECT p FROM Post p LEFT JOIN FETCH p.texts WHERE p.id IN (?1)", postIds).stream()
.count();
}
-
- return new PostPage<>(prevCursor, cursor, nextCursor, forwardResults);
}
@CheckForNull
- protected Text getText() {
+ public Text getText() {
var texts = getTexts();
if (texts.isEmpty()) {
return null;
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 3d56d21..b7c54da 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -9,6 +9,7 @@
mulkcms.tag-base = hub.benkard.de
mulkcms.posts.default-max-results = 25
+mulkcms.newsletter.time-zone = Europe/Vienna
quarkus.datasource.db-kind = postgresql
quarkus.datasource.jdbc.driver = org.postgresql.Driver
@@ -61,6 +62,13 @@
mulkcms.jwt.issuer = https://matthias.benkard.de
mulkcms.jwt.validity = P1D
+# E-mail settings
+quarkus.mailer.from = mulkcms@benkard.de
+quarkus.mailer.host = mail.benkard.de
+quarkus.mailer.port = 587
+quarkus.mailer.start-tls = REQUIRED
+quarkus.mailer.username = mulkcms@benkard.de
+
# Deployment
docker.registry = docker.benkard.de
@@ -89,6 +97,9 @@
kubernetes.env-vars[1].name = QUARKUS_OIDC_CREDENTIALS_SECRET
kubernetes.env-vars[1].secret = mulkcms2-secrets
kubernetes.env-vars[1].value = keycloak-secret
+kubernetes.env-vars[2].name = QUARKUS_MAILER_PASSWORD
+kubernetes.env-vars[2].secret = mulkcms2-secrets
+kubernetes.env-vars[2].value = email-password
kubernetes.secret-volumes[0].volume-name = secrets
kubernetes.secret-volumes[0].secret-name = mulkcms2-secrets
kubernetes.secret-volumes[0].default-mode = 0444
diff --git a/src/main/resources/db/changeLog-1.7.xml b/src/main/resources/db/changeLog-1.7.xml
new file mode 100644
index 0000000..8824115
--- /dev/null
+++ b/src/main/resources/db/changeLog-1.7.xml
@@ -0,0 +1,55 @@
+<?xml version="1.1" encoding="UTF-8" standalone="no"?>
+<databaseChangeLog
+ xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="
+ http://www.liquibase.org/xml/ns/dbchangelog
+ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.10.xsd">
+
+ <changeSet author="mulk" id="1.7-1">
+ <createTable tableName="newsletters" schemaName="benki">
+ <column name="id" type="INTEGER">
+ <constraints primaryKeyName="newsletters_pkey" nullable="false" primaryKey="true"/>
+ </column>
+
+ <column name="date" type="TIMESTAMP WITH TIME ZONE">
+ <constraints nullable="false"/>
+ </column>
+ </createTable>
+
+ <addColumn tableName="bookmarks" schemaName="benki">
+ <column name="newsletter" type="INTEGER">
+ <constraints foreignKeyName="bookmarks_newsletter_fkey" references="benki.newsletters(id)"/>
+ </column>
+ </addColumn>
+
+ <addColumn tableName="lazychat_messages" schemaName="benki">
+ <column name="newsletter" type="INTEGER">
+ <constraints foreignKeyName="lazychat_messages_newsletter_fkey" references="benki.newsletters(id)"/>
+ </column>
+ </addColumn>
+
+ <addColumn tableName="posts" schemaName="benki">
+ <column name="newsletter" type="INTEGER">
+ <constraints foreignKeyName="posts_newsletter_fkey" references="benki.newsletters(id)"/>
+ </column>
+ </addColumn>
+ </changeSet>
+
+ <changeSet author="mulk" id="1.7-2">
+ <createTable tableName="newsletter_subscriptions" schemaName="benki">
+ <column name="id" type="SERIAL" autoIncrement="true">
+ <constraints primaryKeyName="newsletter_subscriptions_pkey" nullable="false" primaryKey="true"/>
+ </column>
+
+ <column name="start_date" type="TIMESTAMP WITH TIME ZONE" defaultValue="now()">
+ <constraints nullable="false"/>
+ </column>
+
+ <column name="email" type="VARCHAR">
+ <constraints nullable="false" unique="true" uniqueConstraintName="newsletter_subscriptions_email_key"/>
+ </column>
+ </createTable>
+ </changeSet>
+
+</databaseChangeLog>
diff --git a/src/main/resources/db/changeLog.xml b/src/main/resources/db/changeLog.xml
index f1c0849..7b4b700 100644
--- a/src/main/resources/db/changeLog.xml
+++ b/src/main/resources/db/changeLog.xml
@@ -13,5 +13,6 @@
<include file="db/changeLog-1.4.xml"/>
<include file="db/changeLog-1.5.xml"/>
<include file="db/changeLog-1.6.xml"/>
+ <include file="db/changeLog-1.7.xml"/>
</databaseChangeLog>
diff --git a/src/main/resources/templates/NewsletterSender/newsletter.txt b/src/main/resources/templates/NewsletterSender/newsletter.txt
new file mode 100644
index 0000000..1c4228f
--- /dev/null
+++ b/src/main/resources/templates/NewsletterSender/newsletter.txt
@@ -0,0 +1,38 @@
+{@int newsletterNumber}
+{@java.time.LocalDate date}
+{@java.util.List<eu.mulk.mulkcms2.benki.bookmarks.Bookmark> bookmarks}
+{@java.util.List<eu.mulk.mulkcms2.benki.lazychat.LazychatMessage> lazychatMessages}
+{@java.lang.String unsubscribeUri}
+New Blog Posts
+==============
+
+{#for post in lazychatMessages}
+* {post.date.humanDate} <https://matthias.benkard.de/posts/{post.id}>
+{post.text.content}
+
+
+{/for}
+
+
+New Bookmarks
+=============
+
+{#for post in bookmarks}
+* {post.date.humanDate} <https://matthias.benkard.de/posts/{post.id}>
+* {post.title}
+* <{post.uri}>
+
+{post.text.description}
+
+
+{/for}
+
+
+
+Your Subscription
+=================
+
+You are receiving this email because you are subscribed to the MulkCMS
+newsletter. To unsubscribe, send an email to:
+
+ <mulkcms+unsubscribe@benkard.de>