diff --git a/.env.sample b/.env.sample
index bb285f3d066..e638e6ecb8a 100644
--- a/.env.sample
+++ b/.env.sample
@@ -38,3 +38,14 @@ export GIT_REPO_CHECKOUT=./tmp/index-co
# to the address `http://localhost:4200/authorize/github`.
export GH_CLIENT_ID=
export GH_CLIENT_SECRET=
+
+# Credentials for configuring Mailgun. You can leave these commented out
+# if you are not interested in actually sending emails. If left empty,
+# a mock email will be sent to a file in your local '/tmp/' directory.
+# If interested in setting up Mailgun to send emails, you will have
+# to create an account with Mailgun and modify these manually.
+# If running a crates mirror on heroku, you can instead add the Mailgun
+# app to your instance and shouldn't have to mess with these.
+# export MAILGUN_SMTP_LOGIN=
+# export MAILGUN_SMTP_PASSWORD=
+# export MAILGUN_SMTP_SERVER=
diff --git a/Cargo.lock b/Cargo.lock
index e7ff161aecb..ad92fe315ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -73,6 +73,14 @@ dependencies = [
"libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)",
]
+[[package]]
+name = "base64"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "byteorder 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "base64"
version = "0.6.0"
@@ -92,6 +100,11 @@ name = "bitflags"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "bufstream"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
[[package]]
name = "byteorder"
version = "1.1.0"
@@ -141,6 +154,7 @@ dependencies = [
"hyper 0.11.2 (registry+https://github.com/rust-lang/crates.io-index)",
"hyper-tls 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)",
"itertools 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "lettre 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
"license-exprs 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
"oauth2 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -175,6 +189,15 @@ name = "cfg-if"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "chrono"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "num 0.1.39 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "chrono"
version = "0.4.0"
@@ -538,6 +561,77 @@ name = "either"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "email"
+version = "0.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "base64 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)",
+ "chrono 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
+ "encoding 0.2.33 (registry+https://github.com/rust-lang/crates.io-index)",
+ "lazy_static 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustc_version 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding"
+version = "0.2.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding-index-japanese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)",
+ "encoding-index-korean 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)",
+ "encoding-index-simpchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)",
+ "encoding-index-singlebyte 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)",
+ "encoding-index-tradchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding-index-japanese"
+version = "1.20141219.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding-index-korean"
+version = "1.20141219.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding-index-simpchinese"
+version = "1.20141219.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding-index-singlebyte"
+version = "1.20141219.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding-index-tradchinese"
+version = "1.20141219.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
+[[package]]
+name = "encoding_index_tests"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+
[[package]]
name = "entities"
version = "1.0.0"
@@ -737,6 +831,22 @@ name = "lazycell"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "lettre"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "bufstream 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
+ "email 0.0.17 (registry+https://github.com/rust-lang/crates.io-index)",
+ "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
+ "mime 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
+ "openssl 0.9.14 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rust-crypto 0.2.36 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)",
+ "uuid 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "libc"
version = "0.2.29"
@@ -851,6 +961,14 @@ dependencies = [
"libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)",
]
+[[package]]
+name = "mime"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "log 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "mime"
version = "0.3.3"
@@ -1170,6 +1288,18 @@ name = "route-recognizer"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "rust-crypto"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "gcc 0.3.51 (registry+https://github.com/rust-lang/crates.io-index)",
+ "libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)",
+ "rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)",
+ "time 0.1.38 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "rustc-demangle"
version = "0.1.4"
@@ -1628,6 +1758,14 @@ name = "utf8-ranges"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
+[[package]]
+name = "uuid"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+dependencies = [
+ "rand 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)",
+]
+
[[package]]
name = "vcpkg"
version = "0.2.2"
@@ -1673,13 +1811,16 @@ dependencies = [
"checksum antidote 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "34fde25430d87a9388dadbe6e34d7f72a462c8b43ac8d309b42b0a8505d7e2a5"
"checksum backtrace 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "72f9b4182546f4b04ebc4ab7f84948953a118bd6021a1b6a6c909e3e94f6be76"
"checksum backtrace-sys 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "3a0d842ea781ce92be2bf78a9b38883948542749640b8378b3b2f03d1fd9f1ff"
+"checksum base64 0.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "30e93c03064e7590d0466209155251b90c22e37fab1daf2771582598b5827557"
"checksum base64 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "96434f987501f0ed4eb336a411e0631ecd1afa11574fe148587adc4ff96143c9"
"checksum bitflags 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d"
"checksum bitflags 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "4efd02e230a02e18f92fc2735f44597385ed02ad8f831e7c1c1156ee5e1ab3a5"
+"checksum bufstream 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "f2f382711e76b9de6c744cc00d0497baba02fb00a787f088c879f01d09468e32"
"checksum byteorder 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ff81738b726f5d099632ceaffe7fb65b90212e8dce59d518729e7e8634032d3d"
"checksum bytes 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)" = "d828f97b58cc5de3e40c421d0cf2132d6b2da4ee0e11b8632fa838f0f9333ad6"
"checksum cargo_metadata 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "be1057b8462184f634c3a208ee35b0f935cfd94b694b26deadccd98732088d7b"
"checksum cfg-if 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d4c819a1287eb618df47cc647173c5c4c66ba19d888a6e50d605672aed3140de"
+"checksum chrono 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "158b0bd7d75cbb6bf9c25967a48a2e9f77da95876b858eadfabaa99cd069de6e"
"checksum chrono 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7c20ebe0b2b08b0aeddba49c609fe7957ba2e33449882cb186a180bc60682fa9"
"checksum civet 0.9.1 (registry+https://github.com/rust-lang/crates.io-index)" = "6263e7af767a5bf9e4d3d0a6c3ceb5f3940ec85cf2fbfee59024b8a264be180f"
"checksum civet-sys 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "958d15372bf28b7983cb35e1d4bf36dd843b0d42e507c1c73aad7150372c5936"
@@ -1718,6 +1859,14 @@ dependencies = [
"checksum dotenv 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d6f0e2bb24d163428d8031d3ebd2d2bd903ad933205a97d0f18c7c1aade380f3"
"checksum dtoa 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "80c8b71fd71146990a9742fc06dcbbde19161a267e0ad4e572c35162f4578c90"
"checksum either 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "18785c1ba806c258137c937e44ada9ee7e69a37e3c72077542cd2f069d78562a"
+"checksum email 0.0.17 (registry+https://github.com/rust-lang/crates.io-index)" = "88560dfb4e8eb3403e6402dfed790d0ef1c16f6d9f80028243a4f09826772f4f"
+"checksum encoding 0.2.33 (registry+https://github.com/rust-lang/crates.io-index)" = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
+"checksum encoding-index-japanese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
+"checksum encoding-index-korean 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
+"checksum encoding-index-simpchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
+"checksum encoding-index-singlebyte 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
+"checksum encoding-index-tradchinese 1.20141219.5 (registry+https://github.com/rust-lang/crates.io-index)" = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
+"checksum encoding_index_tests 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
"checksum entities 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "34302e97cd148c302c39cf11f322bcf3412c06caddd2edf9222c22eac7fd63ef"
"checksum env_logger 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "3ddf21e73e016298f5cb37d6ef8e8da8e39f91f9ec8b0df44b7deb16a9f8cd5b"
"checksum error-chain 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9435d864e017c3c6afeac1654189b06cdb491cf2ff73dbf0d73b0f292f42ff8"
@@ -1742,6 +1891,7 @@ dependencies = [
"checksum language-tags 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a"
"checksum lazy_static 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "3b37545ab726dd833ec6420aaba8231c5b320814b9029ad585555d2a03e94fbf"
"checksum lazycell 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "3b585b7a6811fb03aa10e74b278a0f00f8dd9b45dc681f148bb29fa5cb61859b"
+"checksum lettre 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "062777c2e39d4ccf5a1f30bb308d6464341e7587a5e140f79887d522ca906844"
"checksum libc 0.2.29 (registry+https://github.com/rust-lang/crates.io-index)" = "8a014d9226c2cc402676fbe9ea2e15dd5222cd1dd57f576b5b283178c944a264"
"checksum libgit2-sys 0.6.12 (registry+https://github.com/rust-lang/crates.io-index)" = "df18a822100352d9863b302faf6f8f25c0e77f0e60feb40e5dbe1238b7f13b1d"
"checksum libssh2-sys 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "0db4ec23611747ef772db1c4d650f8bd762f07b461727ec998f953c614024b75"
@@ -1756,6 +1906,7 @@ dependencies = [
"checksum matches 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "100aabe6b8ff4e4a7e32c1c13523379802df0772b82466207ac25b013f193376"
"checksum memchr 0.1.11 (registry+https://github.com/rust-lang/crates.io-index)" = "d8b629fb514376c675b98c1421e80b151d3817ac42d7c667717d282761418d20"
"checksum memchr 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1dbccc0e46f1ea47b9f17e6d67c5a96bd27030519c519c9c91327e31275a47b4"
+"checksum mime 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)" = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0"
"checksum mime 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)" = "153f98dde2b135dece079e5478ee400ae1bab13afa52d66590eacfc40e912435"
"checksum miniz-sys 0.1.9 (registry+https://github.com/rust-lang/crates.io-index)" = "28eaee17666671fa872e567547e8428e83308ebe5808cdf6a0e28397dbe2c726"
"checksum mio 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)" = "dbd91d3bfbceb13897065e97b2ef177a09a438cb33612b2d371bf568819a9313"
@@ -1793,6 +1944,7 @@ dependencies = [
"checksum regex-syntax 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ad890a5eef7953f55427c50575c680c42841653abd2b028b68cd223d157f62db"
"checksum ring 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1f2a6dc7fc06a05e6de183c5b97058582e9da2de0c136eafe49609769c507724"
"checksum route-recognizer 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)" = "cf3255338088df8146ba63d60a9b8e3556f1146ce2973bc05a75181a42ce2256"
+"checksum rust-crypto 0.2.36 (registry+https://github.com/rust-lang/crates.io-index)" = "f76d05d3993fd5f4af9434e8e436db163a12a9d40e1a58a726f27a01dfd12a2a"
"checksum rustc-demangle 0.1.4 (registry+https://github.com/rust-lang/crates.io-index)" = "3058a43ada2c2d0b92b3ae38007a2d0fa5e9db971be260e0171408a4ff471c95"
"checksum rustc-serialize 0.3.24 (registry+https://github.com/rust-lang/crates.io-index)" = "dcf128d1287d2ea9d80910b5f1120d0b8eede3fbf1abe91c40d39ea7d51e6fda"
"checksum rustc_version 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "c5f5376ea5e30ce23c03eb77cbe4962b988deead10910c372b226388b594c084"
@@ -1850,6 +2002,7 @@ dependencies = [
"checksum utf-8 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b6f923c601c7ac48ef1d66f7d5b5b2d9a7ba9c51333ab75a3ddf8d0309185a56"
"checksum utf8-ranges 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)" = "a1ca13c08c41c9c3e04224ed9ff80461d97e121589ff27c753a16cb10830ae0f"
"checksum utf8-ranges 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "662fab6525a98beff2921d7f61a39e7d59e0b425ebc7d0d9e66d316e55124122"
+"checksum uuid 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "7cfec50b0842181ba6e713151b72f4ec84a6a7e2c9c8a8a3ffc37bb1cd16b231"
"checksum vcpkg 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "9e0a7d8bed3178a8fb112199d466eeca9ed09a14ba8ad67718179b4fd5487d0b"
"checksum void 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)" = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
"checksum winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)" = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"
diff --git a/Cargo.toml b/Cargo.toml
index 7172a7a2e9e..d4270b6fee7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,6 +59,7 @@ comrak = { version = "0.1.9", default-features = false }
ammonia = "0.7.0"
docopt = "0.8.1"
itertools = "0.6.0"
+lettre = "0.6"
conduit = "0.8"
conduit-conditional-get = "0.8"
diff --git a/app/components/email-input.js b/app/components/email-input.js
index 88b81063f37..7b22c41b0e2 100644
--- a/app/components/email-input.js
+++ b/app/components/email-input.js
@@ -1,7 +1,12 @@
import Component from '@ember/component';
import { empty } from '@ember/object/computed';
+import { computed } from '@ember/object';
+import { inject as service } from '@ember/service';
export default Component.extend({
+ ajax: service(),
+ flashMessages: service(),
+
type: '',
value: '',
isEditing: false,
@@ -9,7 +14,18 @@ export default Component.extend({
disableSave: empty('user.email'),
notValidEmail: false,
prevEmail: '',
- emailIsNull: true,
+ emailIsNull: computed('user.email', function() {
+ let email = this.get('user.email');
+ return (email == null);
+ }),
+ emailNotVerified: computed('user.email', 'user.email_verified', function() {
+ let email = this.get('user.email');
+ let verified = this.get('user.email_verified');
+
+ return (email != null && !verified);
+ }),
+ isError: false,
+ emailError: '',
actions: {
editEmail() {
@@ -48,6 +64,8 @@ export default Component.extend({
msg = 'An unknown error occurred while saving this email.';
}
this.set('serverError', msg);
+ this.set('isError', true);
+ this.set('emailError', `Error in saving email: ${msg}`);
});
this.set('isEditing', false);
@@ -57,6 +75,30 @@ export default Component.extend({
cancelEdit() {
this.set('isEditing', false);
this.set('value', this.get('prevEmail'));
+ },
+
+ resendEmail() {
+ let user = this.get('user');
+
+ this.get('ajax').raw(`/api/v1/users/${user.id}/resend`, { method: 'PUT',
+ user: {
+ avatar: user.avatar,
+ email: user.email,
+ email_verified: user.email_verified,
+ kind: user.kind,
+ login: user.login,
+ name: user.name,
+ url: user.url
+ }
+ }).catch((error) => {
+ if (error.payload) {
+ this.set('isError', true);
+ this.set('emailError', `Error in resending message: ${error.payload.errors[0].detail}`);
+ } else {
+ this.set('isError', true);
+ this.set('emailError', 'Unknown error in resending message');
+ }
+ });
}
}
});
diff --git a/app/models/user.js b/app/models/user.js
index a5348032596..5f03635b20f 100644
--- a/app/models/user.js
+++ b/app/models/user.js
@@ -2,6 +2,7 @@ import DS from 'ember-data';
export default DS.Model.extend({
email: DS.attr('string'),
+ email_verified: DS.attr('boolean'),
name: DS.attr('string'),
login: DS.attr('string'),
avatar: DS.attr('string'),
diff --git a/app/router.js b/app/router.js
index 13a021ea994..a5c0f57cf9c 100644
--- a/app/router.js
+++ b/app/router.js
@@ -45,6 +45,7 @@ Router.map(function() {
this.route('catchAll', { path: '*path' });
this.route('team', { path: '/teams/:team_id' });
this.route('policies');
+ this.route('confirm', { path: '/confirm/:email_token' });
});
export default Router;
diff --git a/app/routes/confirm.js b/app/routes/confirm.js
new file mode 100644
index 00000000000..391e1ba484a
--- /dev/null
+++ b/app/routes/confirm.js
@@ -0,0 +1,37 @@
+import Ember from 'ember';
+import { inject as service } from '@ember/service';
+
+export default Ember.Route.extend({
+ flashMessages: service(),
+ ajax: service(),
+
+ model(params) {
+ return this.get('ajax').raw(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', data: {} })
+ .then(() => {
+ /* We need this block to reload the user model from the database,
+ without which if we haven't submitted another GET /me after
+ clicking the link and before checking their account info page,
+ the user will still see that their email has not yet been
+ validated and could potentially be confused, resend the email,
+ and set up a situation where their email has been verified but
+ they have an unverified token sitting in the DB.
+
+ Suggestions of a more ideomatic way to fix/test this are welcome!
+ */
+ if (this.session.get('isLoggedIn')) {
+ this.get('ajax').request('/api/v1/me').then((response) => {
+ this.session.set('currentUser', this.store.push(this.store.normalize('user', response.user)));
+ });
+ }
+ })
+ .catch((error) => {
+ if (error.payload) {
+ this.get('flashMessages').queue(`Error in email confirmation: ${error.payload.errors[0].detail}`);
+ return this.replaceWith('index');
+ } else {
+ this.get('flashMessages').queue(`Unknown error in email confirmation`);
+ return this.replaceWith('index');
+ }
+ });
+ }
+});
diff --git a/app/styles/me.scss b/app/styles/me.scss
index 57e21e2a75c..b11ce4e81f9 100644
--- a/app/styles/me.scss
+++ b/app/styles/me.scss
@@ -65,6 +65,47 @@
}
}
+#me-email {
+ border-bottom: 5px solid $gray-border;
+ padding-bottom: 20px;
+ margin-bottom: 20px;
+ @include display-flex;
+ @include flex-direction(column);
+ .row {
+ width: 100%;
+ border: 1px solid #d5d3cb;
+ border-bottom-width: 0px;
+ &:last-child { border-bottom-width: 1px; }
+ padding: 10px 20px;
+ @include display-flex;
+ @include align-items(center);
+ .label {
+ @include flex(1);
+ margin-right: 0.4em;
+ font-weight: bold;
+ }
+ .email {
+ @include flex(20);
+ }
+ .actions {
+ @include display-flex;
+ @include align-items(center);
+ img { margin-left: 10px }
+ }
+ .email-form {
+ display: inline-flex;
+ }
+ .space-right {
+ margin-right: 10px;
+ }
+ }
+ .friendly-message {
+ width: 95%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
#me-api {
@media only screen and (max-width: 350px) {
.api { display: none; }
diff --git a/app/templates/components/email-input.hbs b/app/templates/components/email-input.hbs
index e3c4be93fef..e064dbbaf2d 100644
--- a/app/templates/components/email-input.hbs
+++ b/app/templates/components/email-input.hbs
@@ -1,17 +1,22 @@
+{{#if emailIsNull }}
+
+
Please add your email address. We will only use
+ it to contact you about your account. We promise we'll never share it!
+
+
+{{/if}}
+
{{#if isEditing }}
+
+
User Email
+ {{email-input type='email' value=model.user.email user=model.user}}
+
+
API Access
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index 174234b390c..2b91a10e93b 100644
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -338,6 +338,35 @@ yarn run start:local
And then you should be able to visit http://localhost:4200!
+##### Using Mailgun to Send Emails
+
+We currently have email functionality enabled for confirming a user's email
+address. In development, the sending of emails is simulated by a file
+representing the email being created in your local `/tmp/` directory. If
+you want to test sending real emails, you will have to either set the
+Mailgun environment variables in `.env` manually or run your app instance
+on Heroku and add the Mailgun app.
+
+To set the environment variables manually, create an account and configure
+Mailgun. [These quick start instructions]
+(http://mailgun-documentation.readthedocs.io/en/latest/quickstart.html)
+might be helpful. Once you get the environment variables for the app, you
+will have to add them to the bottom of the `.env` file. You will need to
+fill in the `MAILGUN_SMTP_LOGIN`, `MAILGUN_SMTP_PASSWORD`, and
+`MAILGUN_SMTP_SERVER` fields.
+
+If using Heroku, you should be able to add the app to your instance on your
+dashboard. When your code is pushed and run on Heroku, the environment
+variables should be detected and you should not have to set anything
+manually.
+
+In either case, you should be able to check in your Mailgun account to see
+if emails are being detected and sent. Relevant information should be under
+the 'logs' tab on your Mailgun dashboard. To access, if the variables were
+set up manually, log in to your account. If the variables were set through
+Heroku, you should be able to click on the Mailgun icon in your Heroku
+dashboard, which should take you to your Mailgun dashboard.
+
#### Running the backend tests
In your `.env` file, set `TEST_DATABASE_URL` to a value that's the same as
diff --git a/migrations/20170804200817_add_email_table/down.sql b/migrations/20170804200817_add_email_table/down.sql
new file mode 100644
index 00000000000..68415a79c95
--- /dev/null
+++ b/migrations/20170804200817_add_email_table/down.sql
@@ -0,0 +1,3 @@
+-- This file should undo anything in `up.sql`
+DROP table tokens;
+DROP table emails;
diff --git a/migrations/20170804200817_add_email_table/up.sql b/migrations/20170804200817_add_email_table/up.sql
new file mode 100644
index 00000000000..d1402e4f95b
--- /dev/null
+++ b/migrations/20170804200817_add_email_table/up.sql
@@ -0,0 +1,17 @@
+-- Your SQL goes here
+CREATE table emails (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL UNIQUE,
+ email VARCHAR NOT NULL,
+ verified BOOLEAN DEFAULT false NOT NULL
+);
+
+CREATE table tokens (
+ id SERIAL PRIMARY KEY,
+ email_id INTEGER NOT NULL UNIQUE REFERENCES emails,
+ token VARCHAR NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT now()
+);
+
+INSERT INTO emails (user_id, email)
+ SELECT id, email FROM users WHERE email IS NOT NULL;
diff --git a/src/email.rs b/src/email.rs
new file mode 100644
index 00000000000..38fbff23141
--- /dev/null
+++ b/src/email.rs
@@ -0,0 +1,86 @@
+use dotenv::dotenv;
+use std::env;
+use std::path::Path;
+use util::{CargoResult, bad_request};
+use lettre::email::{EmailBuilder, Email};
+use lettre::transport::file::FileEmailTransport;
+use lettre::transport::EmailTransport;
+use lettre::transport::smtp::{SecurityLevel, SmtpTransportBuilder};
+use lettre::transport::smtp::SUBMISSION_PORT;
+use lettre::transport::smtp::authentication::Mechanism;
+
+#[derive(Debug)]
+pub struct MailgunConfigVars {
+ pub smtp_login: String,
+ pub smtp_password: String,
+ pub smtp_server: String,
+}
+
+pub fn init_config_vars() -> Option
{
+ dotenv().ok();
+
+ match (
+ env::var("MAILGUN_SMTP_LOGIN"),
+ env::var("MAILGUN_SMTP_PASSWORD"),
+ env::var("MAILGUN_SMTP_SERVER"),
+ ) {
+ (Ok(login), Ok(password), Ok(server)) => {
+ Some(MailgunConfigVars {
+ smtp_login: login,
+ smtp_password: password,
+ smtp_server: server,
+ })
+ }
+ _ => None,
+ }
+}
+
+pub fn build_email(
+ recipient: &str,
+ subject: &str,
+ body: &str,
+ mailgun_config: &Option,
+) -> CargoResult {
+ let sender = mailgun_config
+ .as_ref()
+ .map(|s| s.smtp_login.as_str())
+ .unwrap_or("Development Mode");
+
+ let email = EmailBuilder::new()
+ .to(recipient)
+ .from(sender)
+ .subject(subject)
+ .body(body)
+ .build()?;
+
+ Ok(email)
+}
+
+pub fn send_email(recipient: &str, subject: &str, body: &str) -> CargoResult<()> {
+ let mailgun_config = init_config_vars();
+ let email = build_email(recipient, subject, body, &mailgun_config)?;
+
+ match mailgun_config {
+ Some(mailgun_config) => {
+ let mut transport =
+ SmtpTransportBuilder::new((mailgun_config.smtp_server.as_str(), SUBMISSION_PORT))?
+ .credentials(&mailgun_config.smtp_login, &mailgun_config.smtp_password)
+ .security_level(SecurityLevel::AlwaysEncrypt)
+ .smtp_utf8(true)
+ .authentication_mechanism(Mechanism::Plain)
+ .build();
+
+ let result = transport.send(email.clone());
+ result.map_err(|_| bad_request("Error in sending email"))?;
+ }
+ None => {
+ let mut sender = FileEmailTransport::new(Path::new("/tmp"));
+ let result = sender.send(email.clone());
+ result.map_err(
+ |_| bad_request("Email file could not be generated"),
+ )?;
+ }
+ }
+
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
index 6b705f97e10..442ed59d7ec 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -3,7 +3,6 @@
//! All implemented routes are defined in the [middleware](fn.middleware.html) function and
//! implemented in the [category](category/index.html), [keyword](keyword/index.html),
//! [krate](krate/index.html), [user](user/index.html) and [version](version/index.html) modules.
-
#![deny(warnings)]
#![deny(missing_debug_implementations, missing_copy_implementations)]
#![cfg_attr(feature = "clippy", feature(plugin))]
@@ -42,6 +41,7 @@ extern crate tar;
extern crate time;
extern crate toml;
extern crate url;
+extern crate lettre;
extern crate conduit;
extern crate conduit_conditional_get;
@@ -96,6 +96,7 @@ pub mod uploaders;
pub mod user;
pub mod util;
pub mod version;
+pub mod email;
mod local_upload;
mod pagination;
@@ -191,6 +192,8 @@ pub fn middleware(app: Arc) -> MiddlewareBuilder {
C(crate_owner_invitation::list),
);
api_router.get("/summary", C(krate::summary));
+ api_router.put("/confirm/:email_token", C(user::confirm_user_email));
+ api_router.put("/users/:user_id/resend", C(user::regenerate_token_and_send));
let api_router = Arc::new(R404(api_router));
let mut router = RouteBuilder::new();
diff --git a/src/schema.rs b/src/schema.rs
index 80ef28cc823..e0555444750 100644
--- a/src/schema.rs
+++ b/src/schema.rs
@@ -408,6 +408,38 @@ table! {
}
}
+table! {
+ /// Representation of the `emails` table.
+ ///
+ /// (Automatically generated by Diesel.)
+ emails (id) {
+ /// The `id` column of the `emails` table.
+ ///
+ /// Its SQL type is `Int4`.
+ ///
+ /// (Automatically generated by Diesel.)
+ id -> Int4,
+ /// The `user_id` column of the `emails` table.
+ ///
+ /// Its SQL type is `Int4`.
+ ///
+ /// (Automatically generated by Diesel.)
+ user_id -> Int4,
+ /// The `email` column of the `emails` table.
+ ///
+ /// Its SQL type is `Varchar`.
+ ///
+ /// (Automatically generated by Diesel.)
+ email -> Varchar,
+ /// The `verified` column of the `emails` table.
+ ///
+ /// Its SQL type is `Bool`.
+ ///
+ /// (Automatically generated by Diesel.)
+ verified -> Bool,
+ }
+}
+
table! {
/// Representation of the `follows` table.
///
@@ -546,6 +578,38 @@ table! {
}
}
+table! {
+ /// Representation of the `tokens` table.
+ ///
+ /// (Automatically generated by Diesel.)
+ tokens (id) {
+ /// The `id` column of the `tokens` table.
+ ///
+ /// Its SQL type is `Int4`.
+ ///
+ /// (Automatically generated by Diesel.)
+ id -> Int4,
+ /// The `email_id` column of the `tokens` table.
+ ///
+ /// Its SQL type is `Int4`.
+ ///
+ /// (Automatically generated by Diesel.)
+ email_id -> Int4,
+ /// The `token` column of the `tokens` table.
+ ///
+ /// Its SQL type is `Varchar`.
+ ///
+ /// (Automatically generated by Diesel.)
+ token -> Varchar,
+ /// The `created_at` column of the `tokens` table.
+ ///
+ /// Its SQL type is `Timestamp`.
+ ///
+ /// (Automatically generated by Diesel.)
+ created_at -> Timestamp,
+ }
+}
+
table! {
/// Representation of the `users` table.
///
@@ -753,3 +817,4 @@ joinable!(crate_owners -> teams (owner_id));
joinable!(crate_owners -> users (owner_id));
joinable!(readme_rendering -> versions (version_id));
joinable!(crate_owner_invitations -> crates (crate_id));
+joinable!(tokens -> emails (email_id));
diff --git a/src/tests/http-data/user_test_confirm_user_email b/src/tests/http-data/user_test_confirm_user_email
new file mode 100644
index 00000000000..fe51488c706
--- /dev/null
+++ b/src/tests/http-data/user_test_confirm_user_email
@@ -0,0 +1 @@
+[]
diff --git a/src/tests/http-data/user_test_insert_into_email_table b/src/tests/http-data/user_test_insert_into_email_table
new file mode 100644
index 00000000000..fe51488c706
--- /dev/null
+++ b/src/tests/http-data/user_test_insert_into_email_table
@@ -0,0 +1 @@
+[]
diff --git a/src/tests/http-data/user_test_insert_into_email_table_with_email_change b/src/tests/http-data/user_test_insert_into_email_table_with_email_change
new file mode 100644
index 00000000000..fe51488c706
--- /dev/null
+++ b/src/tests/http-data/user_test_insert_into_email_table_with_email_change
@@ -0,0 +1 @@
+[]
diff --git a/src/tests/token.rs b/src/tests/token.rs
index db253afe8c6..d164d243892 100644
--- a/src/tests/token.rs
+++ b/src/tests/token.rs
@@ -385,7 +385,7 @@ fn token_gives_access_to_me() {
req.header("Authorization", &token.token);
let mut response = ok_resp!(middle.call(&mut req));
- let json: ::user::UserShowResponse = ::json(&mut response);
+ let json: ::user::UserShowPrivateResponse = ::json(&mut response);
assert_eq!(json.user.email, user.email);
}
diff --git a/src/tests/user.rs b/src/tests/user.rs
index 456d0c16faa..0ccaa9c44ae 100644
--- a/src/tests/user.rs
+++ b/src/tests/user.rs
@@ -4,7 +4,7 @@ use conduit::{Handler, Method};
use cargo_registry::token::ApiToken;
use cargo_registry::krate::EncodableCrate;
-use cargo_registry::user::{User, NewUser, EncodablePrivateUser};
+use cargo_registry::user::{User, NewUser, EncodablePrivateUser, EncodablePublicUser, Email, Token};
use cargo_registry::version::EncodableVersion;
use diesel::prelude::*;
@@ -16,7 +16,12 @@ struct AuthResponse {
}
#[derive(Deserialize)]
-pub struct UserShowResponse {
+pub struct UserShowPublicResponse {
+ pub user: EncodablePublicUser,
+}
+
+#[derive(Deserialize)]
+pub struct UserShowPrivateResponse {
pub user: EncodablePrivateUser,
}
@@ -48,7 +53,7 @@ fn me() {
let user = ::sign_in(&mut req, &app);
let mut response = ok_resp!(middle.call(&mut req));
- let json: UserShowResponse = ::json(&mut response);
+ let json: UserShowPrivateResponse = ::json(&mut response);
assert_eq!(json.user.email, user.email);
}
@@ -65,15 +70,11 @@ fn show() {
let mut req = ::req(app.clone(), Method::Get, "/api/v1/users/foo");
let mut response = ok_resp!(middle.call(&mut req));
- let json: UserShowResponse = ::json(&mut response);
- // Emails should be None as when on the user/:user_id page, a user's email should
- // not be accessible in order to keep private.
- assert_eq!(None, json.user.email);
+ let json: UserShowPublicResponse = ::json(&mut response);
assert_eq!("foo", json.user.login);
let mut response = ok_resp!(middle.call(req.with_path("/api/v1/users/bar")));
- let json: UserShowResponse = ::json(&mut response);
- assert_eq!(None, json.user.email);
+ let json: UserShowPublicResponse = ::json(&mut response);
assert_eq!("bar", json.user.login);
assert_eq!(Some("https://github.com/bar".into()), json.user.url);
}
@@ -510,3 +511,196 @@ fn test_this_user_cannot_change_that_user_email() {
);
}
+
+/* Given a new user, test that if they sign in with
+ one email, change their email on GitHub, then
+ sign in again, that the email will remain
+ consistent with the original email used on
+ GitHub.
+*/
+#[test]
+fn test_insert_into_email_table() {
+ #[derive(Deserialize)]
+ struct R {
+ user: EncodablePrivateUser,
+ }
+
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+ {
+ let conn = app.diesel_database.get().unwrap();
+ let user = NewUser {
+ gh_id: 1,
+ email: Some("potato@example.com"),
+ ..::new_user("potato")
+ };
+
+ let user = user.create_or_update(&conn).unwrap();
+ ::sign_in_as(&mut req, &user);
+ }
+
+ let mut response = ok_resp!(middle.call(
+ req.with_path("/api/v1/me").with_method(Method::Get),
+ ));
+ let r = ::json::(&mut response);
+ assert_eq!(r.user.email.unwrap(), "potato@example.com");
+ assert_eq!(r.user.login, "potato");
+
+ ::logout(&mut req);
+
+ // What if user changes their github user email
+ {
+ let conn = app.diesel_database.get().unwrap();
+ let user = NewUser {
+ gh_id: 1,
+ email: Some("banana@example.com"),
+ ..::new_user("potato")
+ };
+
+ let user = user.create_or_update(&conn).unwrap();
+ ::sign_in_as(&mut req, &user);
+ }
+
+ let mut response = ok_resp!(middle.call(
+ req.with_path("/api/v1/me").with_method(Method::Get),
+ ));
+ let r = ::json::(&mut response);
+ assert_eq!(r.user.email.unwrap(), "potato@example.com");
+ assert_eq!(r.user.login, "potato");
+}
+
+/* Given a new user, check that when an email is added,
+ changed by user on GitHub, changed on crates.io,
+ that the email remains consistent with that which
+ the user has changed
+*/
+#[test]
+fn test_insert_into_email_table_with_email_change() {
+ #[derive(Deserialize)]
+ struct R {
+ user: EncodablePrivateUser,
+ }
+
+ #[derive(Deserialize)]
+ struct S {
+ ok: bool,
+ }
+
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+ let user = {
+ let conn = app.diesel_database.get().unwrap();
+ let user = NewUser {
+ gh_id: 1,
+ email: Some("potato@example.com"),
+ ..::new_user("potato")
+ };
+
+ let user = user.create_or_update(&conn).unwrap();
+ ::sign_in_as(&mut req, &user);
+ user
+ };
+
+ let mut response = ok_resp!(middle.call(
+ req.with_path("/api/v1/me").with_method(Method::Get),
+ ));
+ let r = ::json::(&mut response);
+ assert_eq!(r.user.email.unwrap(), "potato@example.com");
+ assert_eq!(r.user.login, "potato");
+
+ let body = r#"{"user":{"email":"apricot@apricots.apricot","name":"potato","login":"potato","avatar":"https://avatars0.githubusercontent.com","url":"https://github.com/potato","kind":null}}"#;
+ let mut response = ok_resp!(
+ middle.call(
+ req.with_path(&format!("/api/v1/users/{}", user.id))
+ .with_method(Method::Put)
+ .with_body(body.as_bytes()),
+ )
+ );
+ assert!(::json::(&mut response).ok);
+
+ ::logout(&mut req);
+
+ // What if user changes their github user email
+ {
+ let conn = app.diesel_database.get().unwrap();
+ let user = NewUser {
+ gh_id: 1,
+ email: Some("banana@example.com"),
+ ..::new_user("potato")
+ };
+
+ let user = user.create_or_update(&conn).unwrap();
+ ::sign_in_as(&mut req, &user);
+ }
+
+ let mut response = ok_resp!(middle.call(
+ req.with_path("/api/v1/me").with_method(Method::Get),
+ ));
+ let r = ::json::(&mut response);
+ assert_eq!(r.user.email.unwrap(), "apricot@apricots.apricot");
+ assert_eq!(r.user.login, "potato");
+}
+
+/* Given a new user, test that their email can be added
+ to the email table and a token for the email is generated
+ and added to the token table. When /confirm/:email_token is
+ requested, check that the response back is ok, and that
+ the email_verified field on user is now set to true.
+*/
+#[test]
+fn test_confirm_user_email() {
+ use cargo_registry::schema::{emails, tokens};
+
+ #[derive(Deserialize)]
+ struct R {
+ user: EncodablePrivateUser,
+ }
+
+ #[derive(Deserialize)]
+ struct S {
+ ok: bool,
+ }
+
+ let (_b, app, middle) = ::app();
+ let mut req = ::req(app.clone(), Method::Get, "/me");
+ let user = {
+ let conn = app.diesel_database.get().unwrap();
+ let user = NewUser {
+ email: Some("potato@example.com"),
+ ..::new_user("potato")
+ };
+
+ let user = user.create_or_update(&conn).unwrap();
+ ::sign_in_as(&mut req, &user);
+ user
+ };
+
+ let email_token = {
+ let conn = app.diesel_database.get().unwrap();
+ let email_info = emails::table
+ .filter(emails::user_id.eq(user.id))
+ .first::(&*conn)
+ .unwrap();
+ let token_info = tokens::table
+ .filter(tokens::email_id.eq(email_info.id))
+ .first::(&*conn)
+ .unwrap();
+ token_info.token
+ };
+
+ let mut response = ok_resp!(
+ middle.call(
+ req.with_path(&format!("/api/v1/confirm/{}", email_token))
+ .with_method(Method::Put),
+ )
+ );
+ assert!(::json::(&mut response).ok);
+
+ let mut response = ok_resp!(middle.call(
+ req.with_path("/api/v1/me").with_method(Method::Get),
+ ));
+ let r = ::json::(&mut response);
+ assert_eq!(r.user.email.unwrap(), "potato@example.com");
+ assert_eq!(r.user.login, "potato");
+ assert!(r.user.email_verified);
+}
diff --git a/src/user/mod.rs b/src/user/mod.rs
index 5ba429bb1e2..307ac007b21 100644
--- a/src/user/mod.rs
+++ b/src/user/mod.rs
@@ -5,24 +5,26 @@ use diesel::prelude::*;
use rand::{thread_rng, Rng};
use std::borrow::Cow;
use serde_json;
+use time::Timespec;
use app::RequestApp;
use db::RequestTransaction;
use krate::Follow;
use pagination::Paginate;
use schema::*;
-use util::{RequestUtils, CargoResult, human};
+use util::{RequestUtils, CargoResult, human, bad_request};
use version::EncodableVersion;
use {http, Version};
use owner::{Owner, OwnerKind, CrateOwner};
use krate::Crate;
+use email;
pub use self::middleware::{Middleware, RequestUser, AuthenticationSource};
pub mod middleware;
/// The model representing a row in the `users` database table.
-#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)]
+#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset, Associations)]
pub struct User {
pub id: i32,
pub email: Option,
@@ -44,6 +46,40 @@ pub struct NewUser<'a> {
pub gh_access_token: Cow<'a, str>,
}
+#[derive(Debug, Queryable, AsChangeset, Identifiable, Associations)]
+#[belongs_to(User)]
+pub struct Email {
+ pub id: i32,
+ pub user_id: i32,
+ pub email: String,
+ pub verified: bool,
+}
+
+#[derive(Debug, Insertable, AsChangeset)]
+#[table_name = "emails"]
+pub struct NewEmail {
+ pub user_id: i32,
+ pub email: String,
+ pub verified: bool,
+}
+
+#[derive(Debug, Queryable, AsChangeset, Identifiable, Associations)]
+#[belongs_to(Email)]
+pub struct Token {
+ pub id: i32,
+ pub email_id: i32,
+ pub token: String,
+ pub created_at: Timespec,
+}
+
+#[derive(Debug, Insertable, AsChangeset)]
+#[table_name = "tokens"]
+pub struct NewToken {
+ pub email_id: i32,
+ pub token: String,
+ pub created_at: Timespec,
+}
+
impl<'a> NewUser<'a> {
pub fn new(
gh_id: i32,
@@ -69,6 +105,8 @@ impl<'a> NewUser<'a> {
use diesel::expression::dsl::sql;
use diesel::types::Integer;
use diesel::pg::upsert::*;
+ use time;
+ use diesel::result::Error;
let update_user = NewUser {
email: None,
@@ -88,12 +126,64 @@ impl<'a> NewUser<'a> {
// necessary for most fields in the database to be used as a conflict
// target :)
let conflict_target = sql::("(gh_id) WHERE gh_id > 0");
- insert(&self.on_conflict(
+ let result = insert(&self.on_conflict(
conflict_target,
do_update().set(&update_user),
)).into(users::table)
.get_result(conn)
- .map_err(Into::into)
+ .map_err(Into::into);
+
+ // To send the user an account verification email...
+ if let Some(user_email) = self.email {
+ let user_id = users::table
+ .select(users::id)
+ .filter(users::gh_id.eq(&self.gh_id))
+ .first(&*conn)
+ .unwrap();
+
+ let new_email = NewEmail {
+ user_id: user_id,
+ email: String::from(user_email),
+ verified: false,
+ };
+
+ let email_result: QueryResult = insert(&new_email.on_conflict_do_nothing())
+ .into(emails::table)
+ .get_result(conn)
+ .map_err(Into::into);
+
+ match email_result {
+ Ok(email) => {
+ let token = generate_token();
+ let new_token = NewToken {
+ email_id: email.id,
+ token: token.clone(),
+ created_at: time::now_utc().to_timespec(),
+ };
+
+ insert(&new_token).into(tokens::table).execute(conn)?;
+
+ if send_user_confirm_email(user_email, self.gh_login, &token).is_err() {
+ return Err(Error::NotFound);
+ };
+ }
+ Err(Error::NotFound) => {
+ // This block is reached if a user already has an email stored
+ // in the database. If an email already exists then we will
+ // not overwrite it with the one received from GitHub. Doing
+ // so would force us to resend a verification email each time
+ // the user logs in, which doesn't make any sense.
+ // Thus, we don't consider this case to actually be an error,
+ // so we don't do anything with it, whereas the block below
+ // will catch all other relevant errors.
+ }
+ Err(err) => {
+ return Err(err);
+ }
+ }
+ }
+
+ result
}
}
@@ -116,6 +206,7 @@ pub struct EncodablePrivateUser {
pub id: i32,
pub login: String,
pub email: Option,
+ pub email_verified: bool,
pub name: Option,
pub avatar: Option,
pub url: Option,
@@ -149,7 +240,7 @@ impl User {
}
/// Converts this `User` model into an `EncodablePrivateUser` for JSON serialization.
- pub fn encodable_private(self) -> EncodablePrivateUser {
+ pub fn encodable_private(self, email_verified: bool) -> EncodablePrivateUser {
let User {
id,
email,
@@ -162,6 +253,7 @@ impl User {
EncodablePrivateUser {
id: id,
email: email,
+ email_verified,
avatar: gh_avatar,
login: gh_login,
name: name,
@@ -320,16 +412,31 @@ pub fn me(req: &mut Request) -> CargoResult {
// This change is not preferable, we'd rather fix the request,
// perhaps adding `req.mut_extensions().insert(user)` to the
// update_user route, however this somehow does not seem to work
+
use self::users::dsl::{users, id};
- let user_id = req.user()?.id;
+ use self::emails::dsl::{emails, user_id};
+
+ let u_id = req.user()?.id;
let conn = req.db_conn()?;
- let user = users.filter(id.eq(user_id)).first::(&*conn)?;
+
+ let user_info = users.filter(id.eq(u_id)).first::(&*conn)?;
+ let email_result = emails.filter(user_id.eq(u_id)).first::(&*conn);
+
+ let (email, verified): (Option, bool) = match email_result {
+ Ok(response) => (Some(response.email), response.verified),
+ Err(_) => (None, false),
+ };
+
+ let user = User {
+ email: email,
+ ..user_info
+ };
#[derive(Serialize)]
struct R {
user: EncodablePrivateUser,
}
- Ok(req.json(&R { user: user.encodable_private() }))
+ Ok(req.json(&R { user: user.encodable_private(verified) }))
}
/// Handles the `GET /users/:user_id` route.
@@ -432,6 +539,11 @@ pub fn stats(req: &mut Request) -> CargoResult {
pub fn update_user(req: &mut Request) -> CargoResult {
use diesel::update;
use self::users::dsl::{users, gh_login, email};
+ use self::emails::dsl::user_id;
+ use self::tokens::dsl::email_id;
+ use diesel::insert;
+ use diesel::pg::upsert::*;
+ use time;
let mut body = String::new();
req.body().read_to_string(&mut body)?;
@@ -473,9 +585,156 @@ pub fn update_user(req: &mut Request) -> CargoResult {
.set(email.eq(user_email))
.execute(&*conn)?;
+ let new_email = NewEmail {
+ user_id: user.id,
+ email: String::from(user_email),
+ verified: false,
+ };
+
+ let email_result: QueryResult =
+ insert(&new_email.on_conflict(user_id, do_update().set(&new_email)))
+ .into(emails::table)
+ .get_result(&*conn)
+ .map_err(Into::into);
+
+ let token = generate_token();
+
+ match email_result {
+ Ok(email_response) => {
+ let new_token = NewToken {
+ email_id: email_response.id,
+ token: token.clone(),
+ created_at: time::now_utc().to_timespec(),
+ };
+
+ insert(&new_token.on_conflict(
+ email_id,
+ do_update().set(&new_token),
+ )).into(tokens::table)
+ .execute(&*conn)?;
+ }
+ Err(_) => {
+ return Err(human("Error in creating token"));
+ }
+ }
+
+ let email_result = send_user_confirm_email(user_email, &user.gh_login, &token);
+ email_result.map_err(
+ |_| bad_request("Email could not be sent"),
+ )?;
+
#[derive(Serialize)]
struct R {
ok: bool,
}
Ok(req.json(&R { ok: true }))
}
+
+fn send_user_confirm_email(email: &str, user_name: &str, token: &str) -> CargoResult<()> {
+ // Create a URL with token string as path to send to user
+ // If user clicks on path, look email/user up in database,
+ // make sure tokens match
+
+ let subject = "Please confirm your email address";
+ let body = format!(
+ "Hello {}! Welcome to Crates.io. Please click the
+link below to verify your email address. Thank you!\n
+https://crates.io/confirm/{}",
+ user_name,
+ token
+ );
+
+ email::send_email(email, subject, &body)
+}
+
+/// Handles the `PUT /confirm/:email_token` route
+pub fn confirm_user_email(req: &mut Request) -> CargoResult {
+ // to confirm, we must grab the token on the request as part of the URL
+ // look up the token in the tokens table
+ // find what user the token belongs to
+ // on the email table, change 'verified' to true
+ // delete the token from the tokens table
+ use diesel::{update, delete};
+
+ let conn = req.db_conn()?;
+ let req_token = &req.params()["email_token"];
+
+ let token_info = tokens::table
+ .filter(tokens::token.eq(req_token))
+ .first::(&*conn)
+ .map_err(|_| bad_request("Email token not found."))?;
+
+ let email_info = emails::table
+ .filter(emails::id.eq(token_info.email_id))
+ .first::(&*conn)
+ .map_err(|_| bad_request("Email belonging to token not found."))?;
+
+ update(emails::table.filter(emails::id.eq(email_info.id)))
+ .set(emails::verified.eq(true))
+ .execute(&*conn)
+ .map_err(|_| bad_request("Email verification could not be updated"))?;
+
+ delete(tokens::table.filter(tokens::id.eq(token_info.id)))
+ .execute(&*conn)
+ .map_err(|_| bad_request("Email token could not be deleted"))?;
+
+ #[derive(Serialize)]
+ struct R {
+ ok: bool,
+ }
+ Ok(req.json(&R { ok: true }))
+}
+
+/// Handles `PUT /user/:user_id/resend` route
+pub fn regenerate_token_and_send(req: &mut Request) -> CargoResult {
+ use diesel::pg::upsert::*;
+ use diesel::insert;
+ use time;
+ use self::tokens::dsl::email_id;
+
+ let mut body = String::new();
+ req.body().read_to_string(&mut body)?;
+ let user = req.user()?;
+ let name = &req.params()["user_id"].parse::().ok().unwrap();
+ let conn = req.db_conn()?;
+
+ // need to check if current user matches user to be updated
+ if &user.id != name {
+ return Err(human("current user does not match requested user"));
+ }
+
+ let email_info = emails::table
+ .filter(emails::user_id.eq(user.id))
+ .first::(&*conn)
+ .map_err(|_| bad_request("Email could not be found"))?;
+
+ let token = generate_token();
+
+ let new_token = NewToken {
+ email_id: email_info.id,
+ token: token.clone(),
+ created_at: time::now_utc().to_timespec(),
+ };
+
+ insert(&new_token.on_conflict(
+ email_id,
+ do_update().set(&new_token),
+ )).into(tokens::table)
+ .execute(&*conn)?;
+
+ let email_result = send_user_confirm_email(&email_info.email, &user.gh_login, &token);
+ email_result.map_err(
+ |_| bad_request("Error in sending email"),
+ )?;
+
+ #[derive(Serialize)]
+ struct R {
+ ok: bool,
+ }
+ Ok(req.json(&R { ok: true }))
+}
+
+fn generate_token() -> String {
+ let token: String = thread_rng().gen_ascii_chars().take(26).collect();
+ token
+}