From ba3e58c04e918723233dcc66996399eeeff24007 Mon Sep 17 00:00:00 2001 From: Matthias Andreas Benkard Date: Sun, 1 Nov 2020 12:58:35 +0100 Subject: KB68 Implement newsletter sending. Change-Id: I1d56e40d7f35d6be77fde1a1e8519a91bd2dc3b8 --- pom.xml | 8 ++ .../mulk/mulkcms2/benki/newsletter/Newsletter.java | 29 +++++ .../benki/newsletter/NewsletterSender.java | 117 +++++++++++++++++++++ .../benki/newsletter/NewsletterSubscription.java | 29 +++++ .../java/eu/mulk/mulkcms2/benki/posts/Post.java | 20 +++- src/main/resources/application.properties | 11 ++ src/main/resources/db/changeLog-1.7.xml | 55 ++++++++++ src/main/resources/db/changeLog.xml | 1 + .../templates/NewsletterSender/newsletter.txt | 38 +++++++ 9 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/Newsletter.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java create mode 100644 src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java create mode 100644 src/main/resources/db/changeLog-1.7.xml create mode 100644 src/main/resources/templates/NewsletterSender/newsletter.txt diff --git a/pom.xml b/pom.xml index d0c524e..ab3f5ac 100644 --- a/pom.xml +++ b/pom.xml @@ -140,6 +140,10 @@ io.quarkus quarkus-liquibase + + io.quarkus + quarkus-mailer + io.quarkus quarkus-oidc @@ -156,6 +160,10 @@ io.quarkus quarkus-resteasy-qute + + io.quarkus + quarkus-scheduler + io.quarkus quarkus-smallrye-health 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> 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 bookmarks, List lazychatMessages); + } + + @Scheduled(cron = "0 0 0 ? * Mon") + @Transactional + void run() throws InterruptedException, TimeoutException, ExecutionException { + var session = em.unwrap(Session.class); + + List> 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.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 static java.util.stream.Collectors.toList; 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; @@ -69,6 +71,12 @@ public abstract class Post> extends PanacheEntityBase { @CheckForNull 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 @@ -340,18 +348,22 @@ public abstract class Post> extends PanacheEntityBase { } // 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 > void fetchTexts(Collection 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 @@ quarkus.log.level = INFO 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.signing-key = MulkCMS-IdP 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[0].value = database-password 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + 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 bookmarks} +{@java.util.List lazychatMessages} +{@java.lang.String unsubscribeUri} +New Blog Posts +============== + +{#for post in lazychatMessages} +* {post.date.humanDate} +{post.text.content} + + +{/for} + + +New Bookmarks +============= + +{#for post in bookmarks} +* {post.date.humanDate} +* {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: + + -- cgit v1.2.3