Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
taig committed Mar 12, 2024
0 parents commit 5eef0ba
Show file tree
Hide file tree
Showing 21 changed files with 621 additions and 0 deletions.
39 changes: 39 additions & 0 deletions .github/workflows/branches.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This file was automatically generated by sbt-blowout and should not be edited manually.
# Instead, run blowoutGenerate after making the desired changes to your build definition.
name: CI
'on':
pull_request:
branches:
- main
jobs:
lint:
name: Fatal warnings and code formatting
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
cache: sbt
distribution: temurin
java-version: '21'
- name: Workflows
run: sbt -Dmode=ci blowoutCheck
- name: Code formatting
run: sbt -Dmode=ci scalafmtCheckAll
- name: Fatal warnings
run: sbt -Dmode=ci compile
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
cache: sbt
distribution: temurin
java-version: '21'
- run: sbt test
63 changes: 63 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# This file was automatically generated by sbt-blowout and should not be edited manually.
# Instead, run blowoutGenerate after making the desired changes to your build definition.
name: CI & CD
'on':
push:
branches:
- main
tags:
- '*.*.*'
jobs:
lint:
name: Fatal warnings and code formatting
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
cache: sbt
distribution: temurin
java-version: '21'
- name: Workflows
run: sbt -Dmode=ci blowoutCheck
- name: Code formatting
run: sbt -Dmode=ci scalafmtCheckAll
- name: Fatal warnings
run: sbt -Dmode=ci compile
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
cache: sbt
distribution: temurin
java-version: '21'
- run: sbt test
deploy:
name: Deploy
runs-on: ubuntu-latest
needs:
- lint
- test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Java JDK
uses: actions/setup-java@v4
with:
cache: sbt
distribution: temurin
java-version: '21'
- name: Release
run: sbt -Dmode=release ci-release
env:
PGP_PASSPHRASE: ${{secrets.PGP_PASSPHRASE}}
PGP_SECRET: ${{secrets.PGP_SECRET}}
SONATYPE_PASSWORD: ${{secrets.SONATYPE_PASSWORD}}
SONATYPE_USERNAME: ${{secrets.SONATYPE_USERNAME}}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.scalafmt.conf
.bsp/
target/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Niklas Klein

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
88 changes: 88 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Backmail

> Email templating utilities
## Motivation

Creating beautiful transactional HTML emails is usually a horrible experience. So we tend to resort to the HTML templating feature of our transactional email providers. But this comes with a price: we are getting vendor-locked, argument substituion is wonky and i18n becomes an even more difficult challenge than it already is. This library provides the fundamental building blocks to manage HTML emails in the backend.

## Installation

```sbt
libraryDependencies ++=
"io.taig" %% "backmail-core" % "x.y.z" ::
"io.taig" %% "backmail-circe" % "x.y.z" ::
Nil
```

## Usage

This library provides data structures to describe emails. The provided printers can then be used to turn the email descriptions into plaintext emails, HTML emails or debug messages that hide secret values.

### Define an email

```scala
import io.taig.backmail.dsl.*

val myEmail = email(title = "Title")(
headline(text("Title")),
block(text(plain("Plaintext"), plain(" "), secret("Secret"))),
block(paragraph = false)(text("Lorem ipusm dolar sit amet."), linebreak, text("Lorem ipusm dolar sit amet.")),
space,
button(href = attr(plain("?token="), secret("foobar")))(text("Confirm email")),
space,
block(paragraph = false)(text("Lorem ipusm dolar sit amet."))
)
```

### Print as plaintext

An HTML email should always have a plaintext fallback. This printer is intended to provide this email variant.

```scala
import io.taig.backmail.*
PlaintextPrinter.print(myEmail)

val res0: String = Title

Plaintext Secret

Lorem ipusm dolar sit amet.
Lorem ipusm dolar sit amet.

Confirm email: ?token=foobar

Lorem ipusm dolar sit amet.
```

### Print as debug message

Print the email as a plaintext message with hidden secret values so it is safe to log.

```scala
import io.taig.backmail.*
DebugPrinter.print(myEmail)

val res1: String = Title

Plaintext ******

Lorem ipusm dolar sit amet.
Lorem ipusm dolar sit amet.

Confirm email: ?token=******

Lorem ipusm dolar sit amet.
```

### Print as HTML

Now this is where things get interesting:

1. Start by picking an HTML email templating generator of your choice (e.g. [Maizzle](https://maizzle.com) or [Bootstrap Email](https://bootstrapemail.com))
2. Create a template that includes a headline, a button and paragraphs of text
3. Use the generated HTML to implement your own `HtmlPrinter`

## Disclaimer

This library was created to meet my personal needs and may lack features that are important to you. Contributions welcome (-:
58 changes: 58 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import sbtcrossproject.CrossProject
val Version = new {
val Circe = "0.14.6"
val MUnit = "1.0.0-M11"
val Scala = "3.3.3"
}

def module(identifier: Option[String]): CrossProject = {
CrossProject(identifier.getOrElse("root"), file(identifier.fold(".")("modules/" + _)))(JVMPlatform, JSPlatform)
.crossType(CrossType.Pure)
.withoutSuffixFor(JVMPlatform)
.build()
.settings(
Compile / scalacOptions ++= "-source:future" :: "-rewrite" :: "-new-syntax" :: "-Wunused:all" :: Nil,
name := "backmail" + identifier.fold("")("-" + _)
)
}

inThisBuild(
Def.settings(
developers := List(Developer("taig", "Niklas Klein", "mail@taig.io", url("https://taig.io/"))),
dynverVTagPrefix := false,
homepage := Some(url("https://github.com/taig/backmail/")),
licenses := List("MIT" -> url("https://raw.githubusercontent.com/taig/backmail/main/LICENSE")),
organization := "io.taig",
organizationHomepage := Some(url("https://taig.io/")),
scalaVersion := Version.Scala,
versionScheme := Some("early-semver")
)
)

lazy val root = module(identifier = None)
.enablePlugins(BlowoutYamlPlugin)
.settings(noPublishSettings)
.settings(
blowoutGenerators ++= {
val workflows = file(".github") / "workflows"
BlowoutYamlGenerator.lzy(workflows / "main.yml", GitHubActionsGenerator.main) ::
BlowoutYamlGenerator.lzy(workflows / "branches.yml", GitHubActionsGenerator.branches) ::
Nil
}
)
.aggregate(core, circe)

lazy val core = module(identifier = Some("core"))
.settings(
libraryDependencies ++=
"org.scalameta" %%% "munit" % Version.MUnit % "test" ::
Nil
)

lazy val circe = module(identifier = Some("circe"))
.settings(
libraryDependencies ++=
"io.circe" %%% "circe-core" % Version.Circe ::
Nil
)
.dependsOn(core)
89 changes: 89 additions & 0 deletions modules/circe/src/main/scala/io/taig/backmail/circe.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package io.taig.backmail

import io.circe.Encoder
import io.circe.JsonObject
import io.circe.syntax.*
import io.circe.Decoder
import io.circe.DecodingFailure

object circe:
private object Key:
val Block: String = "block"
val Body: String = "body"
val Button: String = "button"
val Children: String = "children"
val Headline: String = "headline"
val Href: String = "href"
val Linebreak: String = "linebreak"
val Paragraph: String = "paragraph"
val Plain: String = "plain"
val Preheader: String = "preheader"
val Secret: String = "secret"
val Space: String = "space"
val Text: String = "text"
val Title: String = "title"
val Type: String = "type"
val Value: String = "value"

given Decoder[Value] = cursor =>
cursor
.get[String](Key.Type)
.flatMap:
case Key.Plain => cursor.get[String](Key.Value).map(Value.Plain.apply)
case Key.Secret => cursor.get[String](Key.Value).map(Value.Secret.apply)
case tpe => Left(DecodingFailure(s"Unknown: '$tpe'", cursor.downField(Key.Type).history))

given Decoder[Attribute] = Decoder[List[Value]].map(Attribute.apply)

given Encoder[Attribute] = _.toList.asJson

given Encoder.AsObject[Value] =
case Value.Plain(value) => JsonObject(Key.Type := Key.Plain, Key.Value := value)
case Value.Secret(value) => JsonObject(Key.Type := Key.Secret, Key.Value := value)

given Decoder[Template] = cursor =>
cursor
.get[String](Key.Type)
.flatMap:
case Key.Block =>
for
children <- cursor.get[List[Template]](Key.Children)
paragraph <- cursor.get[Boolean](Key.Paragraph)
yield Template.Block(children, paragraph)
case Key.Button =>
for
children <- cursor.get[List[Template]](Key.Children)
href <- cursor.get[Attribute](Key.Href)
yield Template.Button(children, href)
case Key.Headline => cursor.get[List[Template]](Key.Children).map(Template.Headline.apply)
case Key.Linebreak => Right(Template.Linebreak)
case Key.Space => Right(Template.Space)
case Key.Text => cursor.get[List[Value]](Key.Children).map(Template.Text.apply)
case tpe => Left(DecodingFailure(s"Unknown: '$tpe'", cursor.downField(Key.Type).history))

given Encoder.AsObject[Template] =
case Template.Block(children, paragraph) =>
JsonObject(
Key.Type := Key.Block,
Key.Value := JsonObject(Key.Children := children.asJson, Key.Paragraph := paragraph)
)
case Template.Button(children, href) =>
JsonObject(
Key.Type := Key.Button,
Key.Value := JsonObject(Key.Children := children.asJson, Key.Href := href.asJson)
)
case Template.Headline(children) =>
JsonObject(Key.Type := Key.Headline, Key.Value := JsonObject(Key.Children := children))
case Template.Linebreak => JsonObject(Key.Type := Key.Linebreak)
case Template.Space => JsonObject(Key.Type := Key.Space)
case Template.Text(children) => JsonObject(Key.Type := Key.Text, Key.Value := JsonObject(Key.Children := children))

given Decoder[Email] = cursor =>
for
title <- cursor.get[String](Key.Title)
preheader <- cursor.get[Option[String]](Key.Preheader)
body <- cursor.get[List[Template]](Key.Body)
yield Email(title, preheader, body)

given Encoder.AsObject[Email] = email =>
JsonObject(Key.Title := email.title, Key.Preheader := email.preheader, Key.Body := email.body)
7 changes: 7 additions & 0 deletions modules/core/src/main/scala/io/taig/backmail/Attribute.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.taig.backmail

opaque type Attribute = List[Value]

object Attribute:
extension (self: Attribute) def toList: List[Value] = self
def apply(values: List[Value]): Attribute = values
20 changes: 20 additions & 0 deletions modules/core/src/main/scala/io/taig/backmail/DebugPrinter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.taig.backmail

object DebugPrinter extends Printer:
override def print(email: Email): String = email.body.map(print).mkString

def print(template: Template): String = template match
case Template.Block(children, paragrpah) =>
children.map(print).mkString + (if paragrpah then "\n\n" else "")
case Template.Button(children, href) =>
val target = href.toList.map(print).mkString
val label = children.map(print).mkString
s"$label: $target"
case Template.Headline(children) => s"${children.map(print).mkString}\n\n"
case Template.Linebreak => "\n"
case Template.Space => "\n\n"
case Template.Text(children) => children.map(print).mkString

def print(value: Value): String = value match
case Value.Plain(value) => value
case Value.Secret(value) => "*" * value.length
Loading

0 comments on commit 5eef0ba

Please sign in to comment.