🎉 Add html emails.

This commit is contained in:
Andrey Antukh 2020-06-01 13:19:35 +02:00 committed by Alonso Torres
parent 721879aaa8
commit fbd6e395a4
27 changed files with 368 additions and 366 deletions

View file

@ -19,8 +19,9 @@
io.prometheus/simpleclient_hotspot {:mvn/version "0.9.0"} io.prometheus/simpleclient_hotspot {:mvn/version "0.9.0"}
io.prometheus/simpleclient_httpserver {:mvn/version "0.9.0"} io.prometheus/simpleclient_httpserver {:mvn/version "0.9.0"}
selmer/selmer {:mvn/version "1.12.18"}
expound/expound {:mvn/version "0.8.4"} expound/expound {:mvn/version "0.8.4"}
instaparse/instaparse {:mvn/version "1.4.10"}
com.cognitect/transit-clj {:mvn/version "1.0.324"} com.cognitect/transit-clj {:mvn/version "1.0.324"}
io.lettuce/lettuce-core {:mvn/version "5.2.2.RELEASE"} io.lettuce/lettuce-core {:mvn/version "5.2.2.RELEASE"}

View file

@ -0,0 +1,85 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width">
{% block head %}
<title>UXBOX Email</title>
{% endblock %}
{% include "emails/partials/inline_style.html" %}
</head>
<body bgcolor="#f6f6f6" cz-shortcut-listen="true">
<!-- body -->
<table class="body-wrap">
<tbody><tr>
<td></td>
<td class="container" bgcolor="#FFFFFF">
<!-- logo -->
<div class="logo">
<img src="{{assets-uri}}/images/email/logo.png" alt="UXBOX">
</div>
<!-- content -->
<div class="content">
<table>
<tbody><tr>
<td>
{% block content %}
{% endblock %}
</td>
</tr>
</tbody></table>
</div>
<!-- /content -->
</td>
<td></td>
</tr>
</tbody>
</table>
<!-- /body -->
<!-- footer -->
<table class="footer-wrap">
<tbody><tr>
<td></td>
<td class="container">
<!-- content -->
<div class="content">
<table>
<tbody>
<tr>
<td align="center">
<p>UXBOX is the first Open Source prototyping platform that will be embraced by multidisciplinary teams.</p>
</td>
</tr>
<tr>
<td>
<div style="text-align: center; margin: 10px 0;">
<a href="#" target="_blank"><img style="display: inline-block; width: 25px; margin: 0 15px;" src="{{assets-uri}}/images/email/uxbox.png" alt="UXBOX"></a>
<a href="#" target="_blank"><img style="display: inline-block; width: 25px; margin: 0 15px;" src="{{assets-uri}}/images/email/twitter.png" alt="TWITTER"></a>
<a href="#" target="_blank"><img style="display: inline-block; width: 25px; margin: 0 15px;" src="{{assets-uri}}/images/email/github.png" alt="GITHUB"></a>
<a href="#" target="_blank"><img style="display: inline-block; width: 25px; margin: 0 15px;" src="{{assets-uri}}/images/email/instagram.png" alt="INSTAGRAM"></a>
<a href="#" target="_blank"><img style="display: inline-block; width: 25px; margin: 0 15px;" src="{{assets-uri}}/images/email/taiga.png" alt="TAIGA"></a>
</div>
</td>
</tr>
{% comment %}
<tr>
<td align="center">
<p>Sent from UXBOX | <a href="#" target="_blank"><unsubscribe>Email preferences</unsubscribe></a>
</p>
</td>
</tr>
{% endcomment %}
</tbody>
</table>
</div><!-- /content -->
</td>
<td></td>
</tr>
</tbody>
</table>
<!-- /footer -->
</body>
</html>

View file

@ -0,0 +1,19 @@
{% extends "emails/base.html" %}
{% block content %}
<p>Hello {{name}}!</p>
<p>We received a request to change your current email to {{ pending-email }}.</p>
<p>Click to the link below to confirm the change:</p>
<a class="btn-primary" href="{{ public-uri }}/#/auth/verify-token?token={{token}}">Confirm email change</a>
<p>If you received this email by mistake, please consider changing your password
for security reasons.</p>
<p>Enjoy!</p>
<p>The UXBOX team.</p>
{% endblock %}

View file

@ -0,0 +1 @@
Email change

View file

@ -1,19 +1,13 @@
-- begin :subject
Email change.
-- end
-- begin :body-text
Hello {{name}}! Hello {{name}}!
We received a request to change your current email to {{ pendingEmail }}. We received a request to change your current email to {{ pending-email }}.
Click to the link below to confirm the change: Click to the link below to confirm the change:
{{ publicUri }}/#/auth/verify-token?token={{token}} {{ public-uri }}/#/auth/verify-token?token={{token}}
If you received this email by mistake, please consider changing your password If you received this email by mistake, please consider changing your password
for security reasons. for security reasons.
Enjoy! Enjoy!
The UXBOX team. The UXBOX team.
-- end

View file

@ -1,14 +0,0 @@
<html>
<body>
<section style="font-family: Monoid, monospace; font-size: 14px;">
<h1>Available Emails:</h1>
<ul>
{{#emails}}
<li>
<a href="/debug/emails/{{ id }}">{{id}}</a>
</li>
{{/emails}}
</ul>
</section>
</body>
</html>

View file

@ -1,46 +0,0 @@
<table class="footer-wrap">
<tbody>
<tr>
<td></td>
<td class="container">
<div class="content">
<table>
<tbody>
<tr>
<td>
<div style="text-align: center;">
<a href="#" target="_blank">
<img src="{{#static}}images/email/twitter.png{{/static}}"
style="display: inline-block; width: 25px; margin-right: 5px;" />
</a>
<a href="#" target="_blank">
<img src="{{#static}}images/email/github.png{{/static}}"
style="display: inline-block; width: 25px; margin-right: 5px;" />
</a>
<a href="#" target="_blank">
<img src="{{#static}}images/email/linkedin.png{{/static}}"
style="display: inline-block; width: 25px; margin-right: 5px;" />
</a>
</div>
</td>
</tr>
{{#comment}}
<tr>
<td align="center">
<p>
<span>Sent from UXBOX | </span>
<a href="#" target="_blank">
<unsubscribe>Email preferences</unsubscribe>
</a>
</p>
</td>
</tr>
{{/comment}}
</tbody>
</table>
</div>
</td>
<td></td>
</tr>
</tbody>
</table>

View file

@ -1,6 +0,0 @@
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta content="width=device-width" name="viewport" />
<title>title</title>
{{> inline_style }}
</head>

View file

@ -0,0 +1,173 @@
<style>
/* GLOBAL */
* {
margin:0;
padding:0;
font-family: Arial, sans-serif;
font-size: 100%;
line-height: 1.6;
}
img {
max-width: 100%;
width: 100%;
}
body {
-webkit-font-smoothing:antialiased;
-webkit-text-size-adjust:none;
width: 100%!important;
height: 100%;
}
/* ELEMENTS */
a {
color: rgb(35, 211, 161);
text-decoration:none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
.btn-primary {
text-decoration:none;
color: #000;
background-color: #31EFB8;
padding: 10px 30px;
font-weight: bold;
margin: 20px 0;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 3px;
}
.btn-secondary {
text-decoration:none;
color: #000;
background-color: #fff;
padding: 10px 30px;
font-weight: bold;
margin: 20px 0;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 3px;
border: 1px solid #000;
}
.btn-primary:hover,
.btn-secondary:hover {
color: #31EFB8;
background-color: #000;
text-decoration: none;
}
.last {
margin-bottom: 0;
}
.first{
margin-top: 0;
}
.logo {
background-color: #f6f6f6;
padding: 10px;
}
.logo h2 {
color: #000;
font-size: 20px;
font-weight: bold;
margin-top: 15px;
}
.logo img {
max-width: 120px;
}
/* BODY */
table.body-wrap {
width: 100%;
padding: 20px;
}
table.body-wrap .container{
color: #000;
}
/* FOOTER */
table.footer-wrap {
width: 100%;
clear:both!important;
}
.footer-wrap .container p {
font-size: 12px;
color:#666666;
}
table.footer-wrap a{
color: #999;
}
/* TYPOGRAPHY */
h1,h2,h3{
font-family: Arial, sans-serif;
line-height: 1.1;
margin-bottom:15px;
color:#000;
margin: 25px 0 15px;
line-height: 1.2;
font-weight:200;
}
h1 {
color: #000;
font-size: 24px;
font-weight: bold;
}
h2 {
font-size: 22px;
}
h3 {
font-size: 18px;
}
p, ul {
margin-bottom: 20px;
font-weight: normal;
}
ul li {
margin-left:5px;
list-style-position: inside;
}
/* RESPONSIVE */
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block !important;
max-width: 620px !important;
margin: 0 auto !important; /* makes it centered */
clear: both !important;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
padding: 20px;
max-width: 620px;
margin: 0 auto;
display: block;
}
/* Let's make sure tables in the content area are 100% wide */
.content table {
width: 100%;
}
</style>

View file

@ -1,162 +0,0 @@
<style>
/* GLOBAL */
* {
margin:0;
padding:0;
font-family: Arial, sans-serif;
font-size: 100%;
line-height: 1.6;
}
img {
max-width: 100%;
width: 100%;
}
.img-header {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
body {
-webkit-font-smoothing:antialiased;
-webkit-text-size-adjust:none;
width: 100%!important;
height: 100%;
}
/* ELEMENTS */
a {
color: #78dbbe;
text-decoration:none;
font-weight: bold;
}
.btn-primary {
text-decoration:none;
color: #fff;
background-color: #78dbbe;
padding: 10px 30px;
font-weight: bold;
margin: 20px 0;
text-align: center;
cursor: pointer;
display: inline-block;
border-radius: 4px;
}
.btn-primary:hover {
color: #FFF;
background-color: #8eefcf;
}
.last {
margin-bottom: 0;
}
.first{
margin-top: 0;
}
.logo {
background-color: #f6f6f6;
padding: 10px;
text-align: center;
padding-bottom: 25px;
}
.logo h2 {
color: #777;
font-size: 20px;
font-weight: bold;
margin-top: 15px;
}
.logo img {
max-width: 150px;
}
/* BODY */
table.body-wrap {
width: 100%;
padding: 20px;
}
table.body-wrap .container{
border-radius: 5px;
color: #ababab;
}
/* FOOTER */
table.footer-wrap {
width: 100%;
clear:both!important;
}
.footer-wrap .container p {
font-size: 12px;
color:#666;
}
table.footer-wrap a{
color: #999;
}
/* TYPOGRAPHY */
h1,h2,h3{
font-family: Arial, sans-serif;
line-height: 1.1;
margin-bottom:15px;
color:#000;
margin: 40px 0 10px;
line-height: 1.2;
font-weight:200;
}
h1 {
color: #777;
font-size: 28px;
font-weight: bold;
}
h2 {
font-size: 24px;
}
h3 {
font-size: 18px;
}
p, ul {
margin-bottom: 10px;
font-weight: normal;
}
ul li {
margin-left:5px;
list-style-position: inside;
}
/* RESPONSIVE */
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block !important;
max-width: 620px !important;
margin: 0 auto !important; /* makes it centered */
clear: both !important;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
padding: 20px;
max-width: 620px;
margin: 0 auto;
display: block;
}
/* Let's make sure tables in the content area are 100% wide */
.content table {
width: 100%;
}
</style>

View file

@ -0,0 +1,22 @@
{% extends "emails/base.html" %}
{% block content %}
<p>Hello {{name}}!</p>
<p>We received a request to reset your password. Click the link
below to choose a new one:</p>
<a class="btn-primary" href="{{ public-uri }}/#/auth/recovery?token={{token}}">
Reset password.
</a>
<p>
If you received this email by mistake, you can safely ignore
it. Your password won't be changed.
</p>
<p>Enjoy!</p>
<p>The UXBOX team.</p>
{% endblock %}

View file

@ -0,0 +1 @@
Password reset

View file

@ -1,18 +1,12 @@
-- begin :subject
Password reset.
-- end
-- begin :body-text
Hello {{name}}! Hello {{name}}!
We received a request to reset your password. Click the link below to choose a We received a request to reset your password. Click the link below to choose a
new one: new one:
{{ publicUri }}/#/auth/recovery?token={{token}} {{ public-uri }}/#/auth/recovery?token={{token}}
If you received this email by mistake, you can safely ignore it. Your password If you received this email by mistake, you can safely ignore it. Your password
won't be changed. won't be changed.
Enjoy! Enjoy!
The UXBOX team. The UXBOX team.
-- end

View file

@ -0,0 +1,20 @@
{% extends "emails/base.html" %}
{% block content %}
<p>Hello {{name}}!</p>
<p>
Thanks for signing up for your UXBOX account! Please verify your
email using the link below adn get started building mockups and
prototypes today!
</p>
<a class="btn-primary" href="{{public-uri}}/#/auth/verify-token?token={{token}}">
Verify token
</a>
<p>Enjoy!</p>
<p>The UXBOX team.</p>
{% endblock %}

View file

@ -0,0 +1 @@
Verify email.

View file

@ -1,15 +1,9 @@
-- begin :subject
Verify email.
-- end
-- begin :body-text
Hello {{name}}! Hello {{name}}!
Thanks for signing up for your UXBOX account! Please verify your email using the Thanks for signing up for your UXBOX account! Please verify your email using the
link below adn get started building mockups and prototypes today! link below adn get started building mockups and prototypes today!
{{ publicUri }}/#/auth/verify-token?token={{token}} {{ public-uri }}/#/auth/verify-token?token={{token}}
Enjoy! Enjoy!
The UXBOX team. The UXBOX team.
-- end

View file

@ -1,17 +0,0 @@
-- begin :subject
Bienvenue sur UXBOX.
-- end
-- begin :body-text
Bonjour {{user}}!
Bienvenue sur UXBOX.
L'équipe UXBOX.
-- end
-- begin :body-html
<p>Bonjour {{user}} !</p>
<p>Bienvenue sur UXBOX.</p>
<p>L'équipe UXBOX.</p>
-- end

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

View file

@ -32,8 +32,8 @@
:redis-uri "redis://redis/0" :redis-uri "redis://redis/0"
:media-directory "resources/public/media" :media-directory "resources/public/media"
:assets-directory "resources/public/static" :assets-directory "resources/public/static"
:media-uri "http://localhost:6060/media/" :media-uri "http://localhost:6060/media"
:assets-uri "http://localhost:6060/static/" :assets-uri "http://localhost:6060/static"
:sendmail-backend "console" :sendmail-backend "console"
:sendmail-reply-to "no-reply@example.com" :sendmail-reply-to "no-reply@example.com"

View file

@ -24,8 +24,7 @@
(defn default-context (defn default-context
[] []
{:static media/resolve-asset {:assets-uri (:assets-uri cfg/config)
:comment (constantly nil)
:public-uri (:public-uri cfg/config)}) :public-uri (:public-uri cfg/config)})
;; --- Public API ;; --- Public API

View file

@ -12,8 +12,7 @@
[mount.core :as mount :refer [defstate]] [mount.core :as mount :refer [defstate]]
[uxbox.db :as db] [uxbox.db :as db]
[uxbox.config :as cfg] [uxbox.config :as cfg]
[uxbox.util.migrations :as mg] [uxbox.util.migrations :as mg]))
[uxbox.util.template :as tmpl]))
(def +migrations+ (def +migrations+
{:name "uxbox-main" {:name "uxbox-main"

View file

@ -90,7 +90,6 @@
(emails/send! conn emails/register (emails/send! conn emails/register
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:public-url (:public-uri cfg/config)
:token token}) :token token})
profile))) profile)))
@ -339,7 +338,6 @@
(emails/send! conn emails/change-email (emails/send! conn emails/change-email
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:public-url (:public-uri cfg/config)
:pending-email email :pending-email email
:token token}) :token token})
nil))) nil)))
@ -430,7 +428,6 @@
(send-email-notification [conn profile] (send-email-notification [conn profile]
(emails/send! conn emails/password-recovery (emails/send! conn emails/password-recovery
{:to (:email profile) {:to (:email profile)
:public-url (:public-uri cfg/config)
:token (:token profile) :token (:token profile)
:name (:fullname profile)}))] :name (:fullname profile)}))]

View file

@ -52,15 +52,15 @@
:cron (dt/cron "1 1 */1 * * ? *") :cron (dt/cron "1 1 */1 * * ? *")
:fn #'uxbox.tasks.gc/remove-media}]) :fn #'uxbox.tasks.gc/remove-media}])
(defstate worker (defstate tasks-worker
:start (impl/start-worker! {:tasks tasks :start (impl/start-worker! {:tasks tasks
:xtor scheduler}) :xtor scheduler})
:stop (impl/stop! worker)) :stop (impl/stop! tasks-worker))
(defstate scheduler-worker (defstate scheduler-worker
:start (impl/start-scheduler-worker! {:schedule schedule :start (impl/start-scheduler-worker! {:schedule schedule
:xtor scheduler}) :xtor scheduler})
:stop (impl/stop! worker)) :stop (impl/stop! scheduler-worker))
;; --- Public API ;; --- Public API

View file

@ -9,48 +9,13 @@
[clojure.java.io :as io] [clojure.java.io :as io]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[instaparse.core :as insta]
[uxbox.common.spec :as us] [uxbox.common.spec :as us]
[uxbox.common.exceptions :as ex] [uxbox.common.exceptions :as ex]
[uxbox.util.template :as tmpl])) [uxbox.util.template :as tmpl]))
;; --- Impl. ;; --- Impl.
(def ^:private grammar (def ^:private email-path "emails/%(id)s/%(lang)s.%(type)s")
(str "message = part*"
"part = begin header body end; "
"header = tag* eol; "
"tag = space keyword; "
"body = line*; "
"begin = #'--\\s+begin\\s+'; "
"end = #'--\\s+end\\s*' eol*; "
"keyword = #':[\\w\\-]+'; "
"space = #'\\s*'; "
"line = #'.*\\n'; "
"eol = ('\\n' | '\\r\\n'); "))
(def ^:private parse-fn (insta/parser grammar))
(def ^:private email-path "emails/%(id)s/%(lang)s.mustache")
(defn- parse-template
[content]
(loop [state {}
parts (drop 1 (parse-fn content))]
(if-let [[_ _ header body] (first parts)]
(let [type (get-in header [1 2 1])
type (keyword (str/slice type 1))
content (apply str (map second (rest body)))]
(recur (assoc state type (str/trim content " \n"))
(rest parts)))
state)))
(s/def ::subject string?)
(s/def ::body-text string?)
(s/def ::body-html string?)
(s/def ::parsed-email
(s/keys :req-un [::subject ::body-text]
:opt-un [::body-html]))
(defn- build-base-email (defn- build-base-email
[data context] [data context]
@ -66,13 +31,28 @@
(:body-html data) (conj {:type "text/html" (:body-html data) (conj {:type "text/html"
:value (:body-html data)}))}) :value (:body-html data)}))})
(defn- render-email-part
[type id context]
(let [lang (:lang context :en)
path (str/format email-path {:id (name id)
:lang (name lang)
:type (name type)})]
(some-> (io/resource path)
(tmpl/render context))))
(defn- impl-build-email (defn- impl-build-email
[id context] [id context]
(let [lang (:lang context :en) (let [lang (:lang context :en)
path (str/format email-path {:id (name id) :lang (name lang)})] subj (render-email-part :subj id context)
(-> (tmpl/render path context) html (render-email-part :html id context)
(parse-template) text (render-email-part :txt id context)]
(build-base-email context))))
{:subject subj
:content (cond-> []
text (conj {:type "text/plain"
:value text})
html (conj {:type "text/html"
:value html}))}))
;; --- Public API ;; --- Public API

View file

@ -12,57 +12,24 @@
[clojure.walk :as walk] [clojure.walk :as walk]
[clojure.java.io :as io] [clojure.java.io :as io]
[cuerdas.core :as str] [cuerdas.core :as str]
[uxbox.common.exceptions :as ex]) [selmer.parser :as sp]
(:import [uxbox.common.exceptions :as ex]))
java.io.StringReader
java.util.HashMap
java.util.function.Function;
com.github.mustachejava.DefaultMustacheFactory
com.github.mustachejava.Mustache))
(def ^DefaultMustacheFactory +mustache-factory+ (DefaultMustacheFactory.))
(defn- adapt-context
[data]
(walk/postwalk (fn [x]
(cond
(instance? clojure.lang.Named x)
(str/camel (name x))
(instance? clojure.lang.MapEntry x)
x
(fn? x)
(reify Function
(apply [this content]
(try
(x content)
(catch Exception e
(log/error e "Error on executing" x)
""))))
(or (vector? x) (list? x))
(java.util.ArrayList. ^java.util.List x)
(map? x)
(java.util.HashMap. ^java.util.Map x)
(set? x)
(java.util.HashSet. ^java.util.Set x)
:else
x))
data))
;; (sp/cache-off!)
(defn render (defn render
[path context] [path context]
(try (try
(let [context (adapt-context context) (sp/render-file path context)
template (.compile +mustache-factory+ path)] (catch Exception cause
(with-out-str (ex/raise :type :internal
(let [scope (HashMap. ^java.util.Map (walk/stringify-keys context))] :code :template-render-error
(.execute ^Mustache template *out* scope)))) :cause cause))))
(defn render-string
[content context]
(try
(sp/render content context)
(catch Exception cause (catch Exception cause
(ex/raise :type :internal (ex/raise :type :internal
:code :template-render-error :code :template-render-error