diff options
author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2020-11-03 06:16:17 +0100 |
---|---|---|
committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2020-11-03 06:49:44 +0100 |
commit | ff358945f2b210f7f374d3e3e7e36170ee16aed5 (patch) | |
tree | 5f7300b2e3cc9686847b3495ae2b48c579eeb9a2 /src/main | |
parent | ba3e58c04e918723233dcc66996399eeeff24007 (diff) |
KB68 Newsletter registration and deregistration.
Change-Id: Ic79fe64f03ce887879bffc7623e59cb697065ee6
Diffstat (limited to 'src/main')
13 files changed, 311 insertions, 2 deletions
diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java new file mode 100644 index 0000000..1d6ea5c --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/MailRouter.java @@ -0,0 +1,32 @@ +package eu.mulk.mulkcms2.benki.newsletter; + +import javax.enterprise.context.Dependent; +import javax.inject.Inject; +import org.apache.camel.builder.RouteBuilder; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@Dependent +public class MailRouter extends RouteBuilder { + + @ConfigProperty(name = "quarkus.mailer.host") + String emailHost; + + @ConfigProperty(name = "mulkcms.imap.port") + int emailPort; + + @ConfigProperty(name = "quarkus.mailer.username") + String emailUser; + + @ConfigProperty(name = "quarkus.mailer.password") + String emailPassword; + + @Inject NewsletterUnsubscriber newsletterUnsubscriber; + + @Override + public void configure() { + fromF( + "imaps://%s:%d?password=%s&username=%s&searchTerm.to=unsubscribe", + emailHost, emailPort, emailPassword, emailUser) + .process(newsletterUnsubscriber); + } +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java new file mode 100644 index 0000000..a46ee32 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterResource.java @@ -0,0 +1,76 @@ +package eu.mulk.mulkcms2.benki.newsletter; + +import static javax.ws.rs.core.MediaType.TEXT_HTML; + +import io.quarkus.mailer.MailTemplate.MailTemplateInstance; +import io.quarkus.qute.TemplateInstance; +import io.quarkus.qute.api.CheckedTemplate; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import javax.transaction.Transactional; +import javax.ws.rs.ClientErrorException; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response.Status; + +@Path("/newsletter") +@Produces(TEXT_HTML) +public class NewsletterResource { + + @CheckedTemplate + static class Templates { + public static native MailTemplateInstance registrationMail(String registrationKey); + + public static native TemplateInstance index(); + + public static native TemplateInstance completeRegistration(); + + public static native TemplateInstance registered(); + } + + @GET + public TemplateInstance getIndex() { + return Templates.index(); + } + + @POST + @Path("register") + @Transactional + public CompletionStage<TemplateInstance> register(@FormParam("email") String email) { + var existingSubscription = + NewsletterSubscription.<NewsletterSubscription>find("email = ?1", email) + .singleResultOptional(); + if (existingSubscription.isPresent()) { + // If a subscription already exists, act as if we had created it. This provides better + // privacy to users than an error message does. + return CompletableFuture.completedStage(Templates.completeRegistration()); + } + + var subscription = new NewsletterSubscription(); + subscription.email = email; + subscription.persist(); + + var mailText = Templates.registrationMail(subscription.registrationKey); + var sendJob = mailText.subject("MulkCMS newsletter registration").to(email).send(); + return sendJob.thenApply((x) -> Templates.completeRegistration()); + } + + @GET + @Path("finish-registration") + @Transactional + public TemplateInstance finishRegistration(@QueryParam("key") String registrationKey) { + NewsletterSubscription.<NewsletterSubscription>find("registrationKey = ?1", registrationKey) + .singleResultOptional() + .ifPresentOrElse( + s -> s.registrationKey = null, + () -> { + throw new ClientErrorException(Status.BAD_REQUEST); + }); + + return Templates.registered(); + } +} diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java index 1f13c08..59ab37c 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSender.java @@ -84,7 +84,7 @@ public class NewsletterSender { posts.forEach(post -> post.newsletter = newsletter); var subscriberEmails = - NewsletterSubscription.<NewsletterSubscription>streamAll() + NewsletterSubscription.<NewsletterSubscription>stream("registrationKey IS NULL") .map(x -> x.email) .toArray(String[]::new); diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java index 7aeda60..cd50b2e 100644 --- a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java +++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterSubscription.java @@ -1,10 +1,13 @@ package eu.mulk.mulkcms2.benki.newsletter; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import java.math.BigInteger; +import java.security.SecureRandom; import java.time.OffsetDateTime; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; import javax.validation.constraints.Email; @@ -14,9 +17,11 @@ import org.hibernate.annotations.NaturalId; @Table(name = "newsletter_subscriptions", schema = "benki") public class NewsletterSubscription extends PanacheEntityBase { + private static final int registrationKeyBytes = 32; + @Id @Column(name = "id", nullable = false) - @GeneratedValue + @GeneratedValue(strategy = GenerationType.IDENTITY) public Integer id; @Column(name = "start_date", nullable = false) @@ -26,4 +31,14 @@ public class NewsletterSubscription extends PanacheEntityBase { @Column(name = "email", nullable = false) @Email public String email; + + @Column(name = "registration_key", nullable = true) + public String registrationKey = generateRegistrationKey(); + + private static String generateRegistrationKey() { + var secureRandom = new SecureRandom(); + byte[] keyBytes = new byte[registrationKeyBytes]; + secureRandom.nextBytes(keyBytes); + return new BigInteger(keyBytes).abs().toString(36); + } } diff --git a/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java new file mode 100644 index 0000000..0a67ff6 --- /dev/null +++ b/src/main/java/eu/mulk/mulkcms2/benki/newsletter/NewsletterUnsubscriber.java @@ -0,0 +1,63 @@ +package eu.mulk.mulkcms2.benki.newsletter; + +import io.quarkus.mailer.MailTemplate.MailTemplateInstance; +import io.quarkus.qute.api.CheckedTemplate; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.enterprise.context.Dependent; +import javax.mail.internet.InternetAddress; +import javax.transaction.Transactional; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.component.mail.MailMessage; +import org.jboss.logging.Logger; + +@Dependent +public class NewsletterUnsubscriber implements Processor { + + private static final Logger log = Logger.getLogger(NewsletterUnsubscriber.class); + + @CheckedTemplate + static class Templates { + public static native MailTemplateInstance unsubscribedMail(); + } + + @Override + @Transactional + public void process(Exchange exchange) throws Exception { + var message = exchange.getMessage(MailMessage.class); + var mail = message.getMessage(); + + for (var sender : mail.getFrom()) { + if (!(sender instanceof InternetAddress)) { + log.warnf("Tried to unsubscribe, but not an InternetAddress: %s", sender); + continue; + } + + var address = ((InternetAddress) sender).getAddress(); + var subscription = + NewsletterSubscription.<NewsletterSubscription>find("email = ?1", address) + .singleResultOptional(); + subscription.ifPresentOrElse( + s -> { + try { + var sendJob = + Templates.unsubscribedMail() + .subject("Unsubscribed from MulkCMS newsletter") + .to(address) + .send(); + sendJob.toCompletableFuture().get(60, TimeUnit.SECONDS); + + s.delete(); + + log.infof("Unsubscribed: %s (#%d)", s.email, s.id); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new RuntimeException(e); + } + }, + () -> + log.warnf("Tried to unsubscribe, but no subscription found: %s", sender.toString())); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index b7c54da..088e4e0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -68,6 +68,12 @@ quarkus.mailer.host = mail.benkard.de quarkus.mailer.port = 587 quarkus.mailer.start-tls = REQUIRED quarkus.mailer.username = mulkcms@benkard.de +mulkcms.imap.port = 993 + +%dev.quarkus.mailer.host = mail.benkard.de +%dev.quarkus.mailer.from = test@benkard.de +%dev.quarkus.mailer.username = test@benkard.de +%dev.quarkus.mailer.password = test # Deployment docker.registry = docker.benkard.de diff --git a/src/main/resources/db/changeLog-1.7.xml b/src/main/resources/db/changeLog-1.7.xml index 8824115..cfdad49 100644 --- a/src/main/resources/db/changeLog-1.7.xml +++ b/src/main/resources/db/changeLog-1.7.xml @@ -52,4 +52,18 @@ </createTable> </changeSet> + <changeSet author="mulk" id="1.7-3"> + <addColumn tableName="newsletter_subscriptions" schemaName="benki"> + <column name="registration_key" type="VARCHAR"> + <constraints unique="true" uniqueConstraintName="newsletter_subscriptions_registration_key_key"/> + </column> + </addColumn> + + <createIndex tableName="newsletter_subscriptions" + schemaName="benki" + indexName="newsletter_subscriptions_registration_key_idx"> + <column name="registration_key"/> + </createIndex> + </changeSet> + </databaseChangeLog> diff --git a/src/main/resources/templates/NewsletterResource/completeRegistration.html b/src/main/resources/templates/NewsletterResource/completeRegistration.html new file mode 100644 index 0000000..0cdb874 --- /dev/null +++ b/src/main/resources/templates/NewsletterResource/completeRegistration.html @@ -0,0 +1,24 @@ +{#include base.html} + +{#title}Newsletter Registration — Benki{/title} +{#siteSection}Newsletter{/siteSection} +{#wikiClass}this-page{/wikiClass} + +{#nav}{#navbar siteSection="Newsletter" /} + +{#head}{/head} + +{#body} +<article id="newsletter-registration"> + <header> + <h1>Newsletter Registration</h1> + </header> + + <section> + <p>In order to complete your registration, please check your email inbox and click + the link in the confirmation mail.</p> + </section> +</article> +{/body} + +{/include} diff --git a/src/main/resources/templates/NewsletterResource/index.html b/src/main/resources/templates/NewsletterResource/index.html new file mode 100644 index 0000000..84098bd --- /dev/null +++ b/src/main/resources/templates/NewsletterResource/index.html @@ -0,0 +1,32 @@ +{#include base.html} + +{#title}Newsletter — Benki{/title} +{#siteSection}Newsletter{/siteSection} +{#wikiClass}this-page{/wikiClass} + +{#nav}{#navbar siteSection="Newsletter" /} + +{#head}{/head} + +{#body} +<article id="newsletter-registration"> + <header> + <h1>Newsletter</h1> + </header> + + <section> + <p>To get a weekly digest of all posted articles and bookmarks into your email inbox, + enter your email address and submit the form below.</p> + + <p>Your registration is subject to the <a href="/privacy">privacy policy</a>.</p> + + <form class="pure-form pure-form-aligned" action="/newsletter/register" method="post"> + <label for="email-input">E-mail address: </label> + <input type="email" name="email" id="email-input" placeholder="E-mail address" required/> + <input class="pure-button pure-button-primary" type="submit" value="Subscribe"/> + </form> + </section> +</article> +{/body} + +{/include} diff --git a/src/main/resources/templates/NewsletterResource/registered.html b/src/main/resources/templates/NewsletterResource/registered.html new file mode 100644 index 0000000..e82618e --- /dev/null +++ b/src/main/resources/templates/NewsletterResource/registered.html @@ -0,0 +1,26 @@ +{#include base.html} + +{#title}Newsletter Registration — Benki{/title} +{#siteSection}Newsletter{/siteSection} +{#wikiClass}this-page{/wikiClass} + +{#nav}{#navbar siteSection="Newsletter" /} + +{#head}{/head} + +{#body} +<article id="newsletter-registration"> + <header> + <h1>Newsletter Registration</h1> + </header> + + <section> + <p>Thank you. You are now subscribed to the weekly email digest.</p> + + <p>To unsubscribe, send an email to + <a href="mailto:mulkcms+unsubscribe@benkard.de">mulkcms+unsubscribe@benkard.de</a>.</p> + </section> +</article> +{/body} + +{/include} diff --git a/src/main/resources/templates/NewsletterResource/registrationMail.txt b/src/main/resources/templates/NewsletterResource/registrationMail.txt new file mode 100644 index 0000000..c86b385 --- /dev/null +++ b/src/main/resources/templates/NewsletterResource/registrationMail.txt @@ -0,0 +1,12 @@ +{@java.lang.String registrationKey} +Hello! + +Someone (you, one would hope) entered your email address to subscribe to the MulkCMS +benkard.de newsletter. In order to complete your registration, open the following +link in your web browser: + + https://matthias.benkard.de/newsletter/finish-registration?key={registrationKey} + +If someone is playing a trick on you and you would not actually like to subscribe to +the newsletter, you may ignore this email. In this case, your registration record +will be deleted within a week.
\ No newline at end of file diff --git a/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt b/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt new file mode 100644 index 0000000..56f8d0e --- /dev/null +++ b/src/main/resources/templates/NewsletterUnsubscriber/unsubscribedMail.txt @@ -0,0 +1,7 @@ +You have been unsubscribed from the MulkCMS newsletter because we +received an unsubscription request from your email address. + +To register for the newsletter again, open the following page in +your web browser: + + https://matthias.benkard.de/newsletter diff --git a/src/main/resources/templates/tags/navbar.html b/src/main/resources/templates/tags/navbar.html index 63f88f1..d79a952 100644 --- a/src/main/resources/templates/tags/navbar.html +++ b/src/main/resources/templates/tags/navbar.html @@ -9,6 +9,8 @@ <li class='{#if siteSection == "Wiki"}this-page{/}' data-site-section="Wiki"><a href="/wiki/Home">Wiki</a></li> {/if} + <li class='{#if siteSection == "Newsletter"}this-page{/}' data-site-section="Newsletter"><a href="/newsletter">Newsletter</a></li> + <li class='{#if siteSection == "About"}this-page{/}' data-site-section="About"><a href="/about">Contact Info</a></li> <li class='{#if siteSection == "Privacy"}this-page{/}' data-site-section="Privacy"><a href="/privacy">Privacy Policy</a></li> |