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>