git » subscription-tool.git » commit b94f50c

added backend implementation

author Thorsten Ortlepp
2025-12-09 21:31:00 UTC
committer Thorsten Ortlepp
2025-12-09 21:31:00 UTC
parent 89e2902a525ccd60410eae5cc3d1f488c3b35d11

added backend implementation

src/main/java/dev/rubidium/subscriptiontool/SubscriptionToolApplication.java +4 -1
src/main/java/dev/rubidium/subscriptiontool/controller/SubscriptionController.java +29 -9
src/main/java/dev/rubidium/subscriptiontool/model/UnsentMail.java +5 -0
src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Mail.java +71 -0
src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Subscription.java +82 -0
src/main/java/dev/rubidium/subscriptiontool/persistence/repository/MailRepository.java +15 -0
src/main/java/dev/rubidium/subscriptiontool/persistence/repository/SubscriptionRepository.java +14 -0
src/main/java/dev/rubidium/subscriptiontool/properties/MailProperties.java +8 -0
src/main/java/dev/rubidium/subscriptiontool/properties/Translation.java +3 -1
src/main/java/dev/rubidium/subscriptiontool/scheduler/MailScheduler.java +45 -0
src/main/java/dev/rubidium/subscriptiontool/service/CodeService.java +6 -0
src/main/java/dev/rubidium/subscriptiontool/service/MailService.java +6 -0
src/main/java/dev/rubidium/subscriptiontool/service/PersistenceService.java +17 -0
src/main/java/dev/rubidium/subscriptiontool/service/SubscriptionService.java +10 -0
src/main/java/dev/rubidium/subscriptiontool/service/impl/CodeServiceImpl.java +15 -0
src/main/java/dev/rubidium/subscriptiontool/service/impl/MailServiceImpl.java +37 -0
src/main/java/dev/rubidium/subscriptiontool/service/impl/PersistenceServiceImpl.java +102 -0
src/main/java/dev/rubidium/subscriptiontool/service/impl/SubscriptionServiceImpl.java +43 -0
src/main/resources/application.properties +17 -1
src/main/resources/db/migration/V1_0__create_tables.sql +1 -1
src/main/resources/translation.properties +2 -0
src/test/java/dev/rubidium/subscriptiontool/service/CodeServiceTest.java +27 -0

diff --git a/src/main/java/dev/rubidium/subscriptiontool/SubscriptionToolApplication.java b/src/main/java/dev/rubidium/subscriptiontool/SubscriptionToolApplication.java
index f15a1da..a61eab7 100644
--- a/src/main/java/dev/rubidium/subscriptiontool/SubscriptionToolApplication.java
+++ b/src/main/java/dev/rubidium/subscriptiontool/SubscriptionToolApplication.java
@@ -1,12 +1,15 @@
 package dev.rubidium.subscriptiontool;
 
+import dev.rubidium.subscriptiontool.properties.MailProperties;
 import dev.rubidium.subscriptiontool.properties.Translation;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
 @SpringBootApplication
-@EnableConfigurationProperties(Translation.class)
+@EnableConfigurationProperties({Translation.class, MailProperties.class})
+@EnableScheduling
 public class SubscriptionToolApplication {
 
   public static void main(String[] args) {
diff --git a/src/main/java/dev/rubidium/subscriptiontool/controller/SubscriptionController.java b/src/main/java/dev/rubidium/subscriptiontool/controller/SubscriptionController.java
index d7a77f0..31697e2 100644
--- a/src/main/java/dev/rubidium/subscriptiontool/controller/SubscriptionController.java
+++ b/src/main/java/dev/rubidium/subscriptiontool/controller/SubscriptionController.java
@@ -2,6 +2,7 @@ package dev.rubidium.subscriptiontool.controller;
 
 import dev.rubidium.subscriptiontool.model.Subscription;
 import dev.rubidium.subscriptiontool.properties.Translation;
+import dev.rubidium.subscriptiontool.service.SubscriptionService;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.Model;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -17,9 +18,12 @@ public class SubscriptionController {
   private static final String ATTRIBUTE_NAME_SUCCESS = "success";
   private static final String ATTRIBUTE_NAME_EMAIL = "email";
 
+  private final SubscriptionService subscriptionService;
   private final Translation translation;
 
-  public SubscriptionController(final Translation translation) {
+  public SubscriptionController(final SubscriptionService subscriptionService,
+      final Translation translation) {
+    this.subscriptionService = subscriptionService;
     this.translation = translation;
   }
 
@@ -41,10 +45,14 @@ public class SubscriptionController {
 
   @PostMapping("/subscribe")
   public String subscribeSave(@ModelAttribute Subscription subscription, Model model) {
-    // TODO implement saving subscription
+    boolean saved = false;
+    String email = cleanInput(subscription.getEmail());
+    if (!email.isEmpty()) {
+      saved = subscriptionService.saveSubscription(email);
+    }
     model.addAttribute(ATTRIBUTE_NAME_TRANSLATION, translation);
-    model.addAttribute(ATTRIBUTE_NAME_EMAIL, subscription.getEmail());
-    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, true);
+    model.addAttribute(ATTRIBUTE_NAME_EMAIL, email);
+    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, saved);
     return "SubscribeSave";
   }
 
@@ -59,19 +67,31 @@ public class SubscriptionController {
 
   @PostMapping("/unsubscribe")
   public String unsubscribeDelete(@ModelAttribute Subscription subscription, Model model) {
-    // TODO implement deleting subscription
+    boolean deleted = false;
+    String email = cleanInput(subscription.getEmail());
+    if (!email.isEmpty()) {
+      deleted = subscriptionService.deleteSubscription(email);
+    }
     model.addAttribute(ATTRIBUTE_NAME_TRANSLATION, translation);
-    model.addAttribute(ATTRIBUTE_NAME_EMAIL, subscription.getEmail());
-    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, true);
+    model.addAttribute(ATTRIBUTE_NAME_EMAIL, email);
+    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, deleted);
     return "UnsubscribeDelete";
   }
 
 
   @GetMapping("/confirm")
   public String confirm(@RequestParam(name = "code") String code, Model model) {
-    // TODO implement confirming subscription
+    boolean confirmed = false;
+    String confirmationCode = cleanInput(code);
+    if (!confirmationCode.isEmpty()) {
+      confirmed = subscriptionService.confirmSubscription(confirmationCode);
+    }
     model.addAttribute(ATTRIBUTE_NAME_TRANSLATION, translation);
-    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, true);
+    model.addAttribute(ATTRIBUTE_NAME_SUCCESS, confirmed);
     return "Confirm";
   }
+
+  private String cleanInput(String input) {
+    return input == null ? "" : input.trim();
+  }
 }
diff --git a/src/main/java/dev/rubidium/subscriptiontool/model/UnsentMail.java b/src/main/java/dev/rubidium/subscriptiontool/model/UnsentMail.java
new file mode 100644
index 0000000..c095cc0
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/model/UnsentMail.java
@@ -0,0 +1,5 @@
+package dev.rubidium.subscriptiontool.model;
+
+public record UnsentMail(Long id, String email, String code) {
+
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Mail.java b/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Mail.java
new file mode 100644
index 0000000..4f90e39
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Mail.java
@@ -0,0 +1,71 @@
+package dev.rubidium.subscriptiontool.persistence.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.sql.Timestamp;
+
+@Entity
+@Table(name = "mailqueue")
+public class Mail {
+
+  @Id
+  @GeneratedValue(strategy = GenerationType.IDENTITY)
+  @Column(name = "id")
+  private Long id;
+
+  @Column(name = "subscription")
+  private Long subscription;
+
+  @Column(name = "sent")
+  private Boolean sent;
+
+  @Column(name = "creation")
+  private Timestamp creation;
+
+  @Column(name = "completion")
+  private Timestamp completion;
+
+  public Long getId() {
+    return id;
+  }
+
+  public void setId(Long id) {
+    this.id = id;
+  }
+
+  public Long getSubscription() {
+    return subscription;
+  }
+
+  public void setSubscription(Long subscription) {
+    this.subscription = subscription;
+  }
+
+  public Boolean getSent() {
+    return sent;
+  }
+
+  public void setSent(Boolean sent) {
+    this.sent = sent;
+  }
+
+  public Timestamp getCreation() {
+    return creation;
+  }
+
+  public void setCreation(Timestamp creation) {
+    this.creation = creation;
+  }
+
+  public Timestamp getCompletion() {
+    return completion;
+  }
+
+  public void setCompletion(Timestamp completion) {
+    this.completion = completion;
+  }
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Subscription.java b/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Subscription.java
new file mode 100644
index 0000000..e300d56
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/persistence/entity/Subscription.java
@@ -0,0 +1,82 @@
+package dev.rubidium.subscriptiontool.persistence.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.sql.Timestamp;
+
+@Entity
+@Table(name = "subscriptions")
+public class Subscription {
+
+  @Id
+  @GeneratedValue(strategy = GenerationType.IDENTITY)
+  @Column(name = "id")
+  private Long id;
+
+  @Column(name = "mail")
+  private String mail;
+
+  @Column(name = "code")
+  private String code;
+
+  @Column(name = "confirmed")
+  private Boolean confirmed;
+
+  @Column(name = "registration")
+  private Timestamp registration;
+
+  @Column(name = "confirmation")
+  private Timestamp confirmation;
+
+  public Long getId() {
+    return id;
+  }
+
+  public void setId(Long id) {
+    this.id = id;
+  }
+
+  public String getMail() {
+    return mail;
+  }
+
+  public void setMail(String mail) {
+    this.mail = mail;
+  }
+
+  public String getCode() {
+    return code;
+  }
+
+  public void setCode(String code) {
+    this.code = code;
+  }
+
+  public Boolean getConfirmed() {
+    return confirmed;
+  }
+
+  public void setConfirmed(Boolean confirmed) {
+    this.confirmed = confirmed;
+  }
+
+  public Timestamp getRegistration() {
+    return registration;
+  }
+
+  public void setRegistration(Timestamp registration) {
+    this.registration = registration;
+  }
+
+  public Timestamp getConfirmation() {
+    return confirmation;
+  }
+
+  public void setConfirmation(Timestamp confirmation) {
+    this.confirmation = confirmation;
+  }
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/MailRepository.java b/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/MailRepository.java
new file mode 100644
index 0000000..3e77673
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/MailRepository.java
@@ -0,0 +1,15 @@
+package dev.rubidium.subscriptiontool.persistence.repository;
+
+import dev.rubidium.subscriptiontool.persistence.entity.Mail;
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+public interface MailRepository extends JpaRepository<Mail, Long> {
+
+  @Query("SELECT m FROM Mail m WHERE m.subscription = :id")
+  Mail findBySubscription(Long id);
+
+  @Query("SELECT m FROM Mail m WHERE m.sent = false")
+  List<Mail> findUnsentMails();
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/SubscriptionRepository.java b/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/SubscriptionRepository.java
new file mode 100644
index 0000000..51768c9
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/persistence/repository/SubscriptionRepository.java
@@ -0,0 +1,14 @@
+package dev.rubidium.subscriptiontool.persistence.repository;
+
+import dev.rubidium.subscriptiontool.persistence.entity.Subscription;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+public interface SubscriptionRepository extends JpaRepository<Subscription, Long> {
+
+  @Query("SELECT s FROM Subscription s WHERE s.mail = :mail")
+  Subscription findByMail(String mail);
+
+  @Query("SELECT s FROM Subscription s WHERE s.code = :code")
+  Subscription findByCode(String code);
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/properties/MailProperties.java b/src/main/java/dev/rubidium/subscriptiontool/properties/MailProperties.java
new file mode 100644
index 0000000..0ec2e9c
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/properties/MailProperties.java
@@ -0,0 +1,8 @@
+package dev.rubidium.subscriptiontool.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "subscriptiontool.mail")
+public record MailProperties(String from, String url) {
+
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/properties/Translation.java b/src/main/java/dev/rubidium/subscriptiontool/properties/Translation.java
index 89d5ab0..5e70396 100644
--- a/src/main/java/dev/rubidium/subscriptiontool/properties/Translation.java
+++ b/src/main/java/dev/rubidium/subscriptiontool/properties/Translation.java
@@ -16,6 +16,8 @@ public record Translation(String title,
                           String confirmSuccess,
                           String confirmError,
                           String emailPlaceholder,
-                          String back) {
+                          String back,
+                          String mailSubject,
+                          String mailText) {
 
 }
diff --git a/src/main/java/dev/rubidium/subscriptiontool/scheduler/MailScheduler.java b/src/main/java/dev/rubidium/subscriptiontool/scheduler/MailScheduler.java
new file mode 100644
index 0000000..fa2c4cc
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/scheduler/MailScheduler.java
@@ -0,0 +1,45 @@
+package dev.rubidium.subscriptiontool.scheduler;
+
+import dev.rubidium.subscriptiontool.properties.MailProperties;
+import dev.rubidium.subscriptiontool.properties.Translation;
+import dev.rubidium.subscriptiontool.service.MailService;
+import dev.rubidium.subscriptiontool.service.PersistenceService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+@Service
+public class MailScheduler {
+
+  private static final Logger logger = LoggerFactory.getLogger(MailScheduler.class);
+
+  private final PersistenceService persistenceService;
+  private final MailService mailService;
+  private final Translation translation;
+  private final MailProperties mailProperties;
+
+  public MailScheduler(final PersistenceService persistenceService,
+      final MailService mailService, final Translation translation,
+      final MailProperties mailProperties) {
+    this.persistenceService = persistenceService;
+    this.mailService = mailService;
+    this.translation = translation;
+    this.mailProperties = mailProperties;
+  }
+
+  @Scheduled(cron = "${subscriptiontool.scheduler.cron}",
+      zone = "${subscriptiontool.scheduler.zone}")
+  public void sendMails() {
+    persistenceService.getUnsentMails().forEach(mail -> {
+      logger.info("Sending double opt-in mail for subscriber # {}", mail.id());
+      String link = mailProperties.url() + mail.code();
+      String message = translation.mailText().formatted(link);
+      if (mailService.sendMail(
+          mailProperties.from(), mail.email(), translation.mailSubject(), message)) {
+        persistenceService.updateMail(mail.id());
+      }
+    });
+  }
+
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/CodeService.java b/src/main/java/dev/rubidium/subscriptiontool/service/CodeService.java
new file mode 100644
index 0000000..3224e3a
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/CodeService.java
@@ -0,0 +1,6 @@
+package dev.rubidium.subscriptiontool.service;
+
+public interface CodeService {
+
+  String generateCode(String input);
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/MailService.java b/src/main/java/dev/rubidium/subscriptiontool/service/MailService.java
new file mode 100644
index 0000000..1d31e4c
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/MailService.java
@@ -0,0 +1,6 @@
+package dev.rubidium.subscriptiontool.service;
+
+public interface MailService {
+
+  boolean sendMail(String from, String to, String subject, String body);
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/PersistenceService.java b/src/main/java/dev/rubidium/subscriptiontool/service/PersistenceService.java
new file mode 100644
index 0000000..1e6890b
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/PersistenceService.java
@@ -0,0 +1,17 @@
+package dev.rubidium.subscriptiontool.service;
+
+import dev.rubidium.subscriptiontool.model.UnsentMail;
+import java.util.List;
+
+public interface PersistenceService {
+
+  boolean createSubscription(String email, String code);
+
+  boolean deleteSubscription(String email);
+
+  boolean updateSubscription(String code);
+
+  List<UnsentMail> getUnsentMails();
+
+  void updateMail(Long id);
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/SubscriptionService.java b/src/main/java/dev/rubidium/subscriptiontool/service/SubscriptionService.java
new file mode 100644
index 0000000..9662616
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/SubscriptionService.java
@@ -0,0 +1,10 @@
+package dev.rubidium.subscriptiontool.service;
+
+public interface SubscriptionService {
+
+  boolean saveSubscription(String email);
+
+  boolean deleteSubscription(String email);
+
+  boolean confirmSubscription(String code);
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/impl/CodeServiceImpl.java b/src/main/java/dev/rubidium/subscriptiontool/service/impl/CodeServiceImpl.java
new file mode 100644
index 0000000..0743e20
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/impl/CodeServiceImpl.java
@@ -0,0 +1,15 @@
+package dev.rubidium.subscriptiontool.service.impl;
+
+import dev.rubidium.subscriptiontool.service.CodeService;
+import java.util.UUID;
+import org.springframework.stereotype.Service;
+
+@Service
+public class CodeServiceImpl implements CodeService {
+
+  @Override
+  public String generateCode(String input) {
+    UUID uuid = UUID.nameUUIDFromBytes(input.getBytes());
+    return uuid.toString().replace("-", "");
+  }
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/impl/MailServiceImpl.java b/src/main/java/dev/rubidium/subscriptiontool/service/impl/MailServiceImpl.java
new file mode 100644
index 0000000..df031cd
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/impl/MailServiceImpl.java
@@ -0,0 +1,37 @@
+package dev.rubidium.subscriptiontool.service.impl;
+
+import dev.rubidium.subscriptiontool.service.MailService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.mail.MailException;
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+import org.springframework.stereotype.Service;
+
+@Service
+public class MailServiceImpl implements MailService {
+
+  private static final Logger logger = LoggerFactory.getLogger(MailServiceImpl.class);
+
+  private final JavaMailSender mailSender;
+
+  public MailServiceImpl(final JavaMailSender mailSender) {
+    this.mailSender = mailSender;
+  }
+
+  @Override
+  public boolean sendMail(String from, String to, String subject, String body) {
+    try {
+      SimpleMailMessage message = new SimpleMailMessage();
+      message.setFrom(from);
+      message.setTo(to);
+      message.setSubject(subject);
+      message.setText(body);
+      mailSender.send(message);
+      return true;
+    } catch (MailException ex) {
+      logger.error("Error sending mail", ex);
+      return false;
+    }
+  }
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/impl/PersistenceServiceImpl.java b/src/main/java/dev/rubidium/subscriptiontool/service/impl/PersistenceServiceImpl.java
new file mode 100644
index 0000000..845c82c
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/impl/PersistenceServiceImpl.java
@@ -0,0 +1,102 @@
+package dev.rubidium.subscriptiontool.service.impl;
+
+import dev.rubidium.subscriptiontool.model.UnsentMail;
+import dev.rubidium.subscriptiontool.persistence.entity.Mail;
+import dev.rubidium.subscriptiontool.persistence.entity.Subscription;
+import dev.rubidium.subscriptiontool.persistence.repository.MailRepository;
+import dev.rubidium.subscriptiontool.persistence.repository.SubscriptionRepository;
+import dev.rubidium.subscriptiontool.service.PersistenceService;
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+public class PersistenceServiceImpl implements PersistenceService {
+
+  private static final Logger logger = LoggerFactory.getLogger(PersistenceServiceImpl.class);
+
+  private final SubscriptionRepository subscriptionRepository;
+  private final MailRepository mailRepository;
+
+  PersistenceServiceImpl(final SubscriptionRepository subscriptionRepository,
+      final MailRepository mailRepository) {
+    this.subscriptionRepository = subscriptionRepository;
+    this.mailRepository = mailRepository;
+  }
+
+  @Override
+  public boolean createSubscription(String email, String code) {
+    Subscription registered = subscriptionRepository.findByMail(email);
+    if (registered == null) {
+      Subscription subscription = new Subscription();
+      subscription.setMail(email);
+      subscription.setCode(code);
+      subscription.setConfirmed(Boolean.FALSE);
+      subscription.setRegistration(Timestamp.valueOf(LocalDateTime.now()));
+      subscriptionRepository.saveAndFlush(subscription);
+
+      Long subscriptionId = subscriptionRepository.findByMail(email).getId();
+
+      Mail mail = new Mail();
+      mail.setSubscription(subscriptionId);
+      mail.setSent(Boolean.FALSE);
+      mail.setCreation(Timestamp.valueOf(LocalDateTime.now()));
+      mailRepository.saveAndFlush(mail);
+
+      return true;
+    }
+    logger.warn("Subscriber # {} tried to subscribe again", registered.getId());
+    return false;
+  }
+
+  @Override
+  public boolean deleteSubscription(String email) {
+    Subscription subscription = subscriptionRepository.findByMail(email);
+    if (subscription != null) {
+      Mail mail = mailRepository.findBySubscription(subscription.getId());
+      if (mail != null) {
+        mailRepository.deleteById(mail.getId());
+      }
+      subscriptionRepository.deleteById(subscription.getId());
+      return true;
+    }
+    logger.warn("No subscription found for email {}", email);
+    return false;
+  }
+
+  @Override
+  public boolean updateSubscription(String code) {
+    Subscription subscription = subscriptionRepository.findByCode(code);
+    if (subscription != null) {
+      subscription.setConfirmed(Boolean.TRUE);
+      subscription.setConfirmation(Timestamp.valueOf(LocalDateTime.now()));
+      subscriptionRepository.saveAndFlush(subscription);
+      return true;
+    }
+    logger.warn("No subscription found for code {}", code);
+    return false;
+  }
+
+  @Override
+  public List<UnsentMail> getUnsentMails() {
+    var mails = new ArrayList<UnsentMail>();
+    mailRepository.findUnsentMails().forEach(mail -> {
+      subscriptionRepository.findById(mail.getSubscription()).ifPresent(subscription ->
+          mails.add(new UnsentMail(mail.getId(), subscription.getMail(), subscription.getCode())));
+    });
+    return mails;
+  }
+
+  @Override
+  public void updateMail(Long id) {
+    mailRepository.findById(id).ifPresent(mail -> {
+      mail.setSent(Boolean.TRUE);
+      mail.setCompletion(Timestamp.valueOf(LocalDateTime.now()));
+      mailRepository.saveAndFlush(mail);
+    });
+  }
+}
diff --git a/src/main/java/dev/rubidium/subscriptiontool/service/impl/SubscriptionServiceImpl.java b/src/main/java/dev/rubidium/subscriptiontool/service/impl/SubscriptionServiceImpl.java
new file mode 100644
index 0000000..856ea18
--- /dev/null
+++ b/src/main/java/dev/rubidium/subscriptiontool/service/impl/SubscriptionServiceImpl.java
@@ -0,0 +1,43 @@
+package dev.rubidium.subscriptiontool.service.impl;
+
+import dev.rubidium.subscriptiontool.service.CodeService;
+import dev.rubidium.subscriptiontool.service.PersistenceService;
+import dev.rubidium.subscriptiontool.service.SubscriptionService;
+import java.time.LocalDateTime;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SubscriptionServiceImpl implements SubscriptionService {
+
+  private static final Logger logger = LoggerFactory.getLogger(SubscriptionServiceImpl.class);
+
+  private final CodeService codeService;
+  private final PersistenceService persistenceService;
+
+  public SubscriptionServiceImpl(final CodeService codeService,
+      final PersistenceService persistenceService) {
+    this.codeService = codeService;
+    this.persistenceService = persistenceService;
+  }
+
+  @Override
+  public boolean saveSubscription(String email) {
+    logger.info("Saving a new subscription at {}", LocalDateTime.now());
+    String code = codeService.generateCode(email);
+    return persistenceService.createSubscription(email, code);
+  }
+
+  @Override
+  public boolean deleteSubscription(String email) {
+    logger.info("Deleting a subscription at {}", LocalDateTime.now());
+    return persistenceService.deleteSubscription(email);
+  }
+
+  @Override
+  public boolean confirmSubscription(String code) {
+    logger.info("Confirming a subscription at {}", LocalDateTime.now());
+    return persistenceService.updateSubscription(code);
+  }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 6af908c..778e402 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -6,13 +6,29 @@ info.app-author=Thorsten Ortlepp
 info.app-license=MIT License
 
 spring.datasource.url=jdbc:postgresql://localhost:5000/subscriptiontool
-spring.datasource.username=postgres
+spring.datasource.username=USERNAME
 spring.datasource.password=PASSWORD
 
+spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
+spring.jpa.properties.hibernate.default_schema=public
+spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
+
+spring.mail.host=smtp.example.com
+spring.mail.port=587
+spring.mail.username=USERNAME
+spring.mail.password=PASSWORD
+spring.mail.properties.mail.smtp.auth=true
+spring.mail.properties.mail.smtp.starttls.enable=true
+
 management.info.build.enabled=true
 management.info.env.enabled=true
 management.info.java.enabled=true
 management.info.os.enabled=true
 management.endpoints.web.exposure.include=health,info,metrics,flyway
 
+subscriptiontool.mail.from=hello@example.com
+subscriptiontool.mail.url=https://example.com/confirm?code=
+subscriptiontool.scheduler.cron=0 * * * * *
+subscriptiontool.scheduler.zone=Europe/Berlin
+
 spring.config.import=translation.properties
diff --git a/src/main/resources/db/migration/V1_0__create_tables.sql b/src/main/resources/db/migration/V1_0__create_tables.sql
index 616fd1e..2f23b2c 100644
--- a/src/main/resources/db/migration/V1_0__create_tables.sql
+++ b/src/main/resources/db/migration/V1_0__create_tables.sql
@@ -1,7 +1,7 @@
 CREATE TABLE subscriptions (
   id BIGINT GENERATED ALWAYS AS IDENTITY UNIQUE,
   mail VARCHAR(255) NOT NULL UNIQUE,
-  code CHAR(64) NOT NULL,
+  code CHAR(32) NOT NULL UNIQUE,
   confirmed BOOLEAN NOT NULL DEFAULT FALSE,
   registration TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
   confirmation TIMESTAMP
diff --git a/src/main/resources/translation.properties b/src/main/resources/translation.properties
index ef468fc..749deeb 100644
--- a/src/main/resources/translation.properties
+++ b/src/main/resources/translation.properties
@@ -12,3 +12,5 @@ translation.confirmSuccess=Your subscription has been confirmed.
 translation.confirmError=Error confirming your subscription. Please try again later.
 translation.emailPlaceholder=Your email address
 translation.back=Back
+translation.mailSubject=Please confirm your subscription
+translation.mailText=Hi,\n\nplease confirm your subscription by clicking the following link:\n%s\n\nBest regards
diff --git a/src/test/java/dev/rubidium/subscriptiontool/service/CodeServiceTest.java b/src/test/java/dev/rubidium/subscriptiontool/service/CodeServiceTest.java
new file mode 100644
index 0000000..54222f8
--- /dev/null
+++ b/src/test/java/dev/rubidium/subscriptiontool/service/CodeServiceTest.java
@@ -0,0 +1,27 @@
+package dev.rubidium.subscriptiontool.service;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import dev.rubidium.subscriptiontool.service.impl.CodeServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class CodeServiceTest {
+
+  private CodeService codeService;
+
+  @BeforeEach
+  void setUp() {
+    codeService = new CodeServiceImpl();
+  }
+
+  @Test
+  void testGenerateCode() {
+    final String input = "test@example.com";
+    final String expected = "55502f40dc8b3c769880b10874abc9d0";
+
+    var result = codeService.generateCode(input);
+
+    assertEquals(expected, result);
+  }
+}