Alert when we stop learning (#375)

* Send password reset alert

* Send inactive reminder

* Send subscription confirmation email
This commit is contained in:
Sung Won Cho 2019-12-24 13:07:27 +07:00 committed by GitHub
commit 3152ca770b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 2013 additions and 674 deletions

View file

@ -14,6 +14,12 @@ The following log documents the history of the server project.
None
### 0.3.4 - 2019-12-24
#### Added
- Send email when user has stopped learning (#375)
### 0.3.3 - 2019-12-17
#### Added

View file

@ -2263,6 +2263,7 @@
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-2.0.1.tgz",
"integrity": "sha512-88em58dDVB/KzPEx1X0N3LwFfYZPyDc4B6eF38M1rk9VTZMbxXXgjugz8mmwpS9Ox4BDZ+t6t3QP5+/gazweIA==",
"dev": true,
"optional": true,
"requires": {
"p-finally": "^1.0.0"
}
@ -2310,6 +2311,7 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz",
"integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==",
"dev": true,
"optional": true,
"requires": {
"readable-stream": "^2.3.5",
"safe-buffer": "^5.1.1"
@ -2343,7 +2345,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
@ -2477,6 +2480,7 @@
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
"integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
"dev": true,
"optional": true,
"requires": {
"base64-js": "^1.0.2",
"ieee754": "^1.1.4"
@ -2487,6 +2491,7 @@
"resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz",
"integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==",
"dev": true,
"optional": true,
"requires": {
"buffer-alloc-unsafe": "^1.1.0",
"buffer-fill": "^1.0.0"
@ -2496,7 +2501,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz",
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
"dev": true
"dev": true,
"optional": true
},
"buffer-crc32": {
"version": "0.2.13",
@ -2514,7 +2520,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
"integrity": "sha1-+PeLdniYiO858gXNY39o5wISKyw=",
"dev": true
"dev": true,
"optional": true
},
"buffer-from": {
"version": "1.1.1",
@ -2706,6 +2713,7 @@
"resolved": "https://registry.npmjs.org/caw/-/caw-2.0.1.tgz",
"integrity": "sha512-Cg8/ZSBEa8ZVY9HspcGUYaK63d/bN7rqS3CYCzEGUxuYv6UlmcjzDUz2fCFFHyTvUW5Pk0I+3hkA3iXlIj6guA==",
"dev": true,
"optional": true,
"requires": {
"get-proxy": "^2.0.0",
"isurl": "^1.0.0-alpha5",
@ -2935,6 +2943,7 @@
"resolved": "https://registry.npmjs.org/commander/-/commander-2.8.1.tgz",
"integrity": "sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ=",
"dev": true,
"optional": true,
"requires": {
"graceful-readlink": ">= 1.0.0"
}
@ -3138,6 +3147,7 @@
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.12.tgz",
"integrity": "sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==",
"dev": true,
"optional": true,
"requires": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
@ -3167,6 +3177,7 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "5.1.2"
}
@ -3303,6 +3314,7 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
"integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
"dev": true,
"optional": true,
"requires": {
"lru-cache": "^4.0.1",
"shebang-command": "^1.2.0",
@ -3353,6 +3365,7 @@
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz",
"integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==",
"dev": true,
"optional": true,
"requires": {
"mdn-data": "2.0.4",
"source-map": "^0.6.1"
@ -3362,7 +3375,8 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3446,6 +3460,7 @@
"resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.0.tgz",
"integrity": "sha1-eu3YVCflqS2s/lVnSnxQXpbQH50=",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.0.0",
"decompress-tarbz2": "^4.0.0",
@ -3462,6 +3477,7 @@
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
"integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==",
"dev": true,
"optional": true,
"requires": {
"pify": "^3.0.0"
},
@ -3470,7 +3486,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3478,7 +3495,8 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3487,6 +3505,7 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
"integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
"dev": true,
"optional": true,
"requires": {
"mimic-response": "^1.0.0"
}
@ -3496,6 +3515,7 @@
"resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz",
"integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==",
"dev": true,
"optional": true,
"requires": {
"file-type": "^5.2.0",
"is-stream": "^1.1.0",
@ -3506,7 +3526,8 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz",
"integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3515,6 +3536,7 @@
"resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz",
"integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.1.0",
"file-type": "^6.1.0",
@ -3527,7 +3549,8 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz",
"integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3536,6 +3559,7 @@
"resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz",
"integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==",
"dev": true,
"optional": true,
"requires": {
"decompress-tar": "^4.1.1",
"file-type": "^5.2.0",
@ -3546,7 +3570,8 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz",
"integrity": "sha1-LdvqfHP/42No365J3DOMBYwritY=",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3555,6 +3580,7 @@
"resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz",
"integrity": "sha1-3qrM39FK6vhVePczroIQ+bSEj2k=",
"dev": true,
"optional": true,
"requires": {
"file-type": "^3.8.0",
"get-stream": "^2.2.0",
@ -3566,13 +3592,15 @@
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz",
"integrity": "sha1-JXoHg4TR24CHvESdEH1SpSZyuek=",
"dev": true
"dev": true,
"optional": true
},
"get-stream": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz",
"integrity": "sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4=",
"dev": true,
"optional": true,
"requires": {
"object-assign": "^4.0.1",
"pinkie-promise": "^2.0.0"
@ -3582,7 +3610,8 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3828,7 +3857,8 @@
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=",
"dev": true
"dev": true,
"optional": true
},
"duplexify": {
"version": "3.6.1",
@ -3960,6 +3990,7 @@
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.0-next.1.tgz",
"integrity": "sha512-7MmGr03N7Rnuid6+wyhD9sHNE2n4tFSwExnU2lQl3lIo2ShXWGePY80zYaoMOmILWv57H0amMjZGHNzzGG70Rw==",
"dev": true,
"optional": true,
"requires": {
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
@ -3978,13 +4009,15 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
"dev": true
"dev": true,
"optional": true
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -3993,6 +4026,7 @@
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
"integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
"dev": true,
"optional": true,
"requires": {
"is-callable": "^1.1.4",
"is-date-object": "^1.0.1",
@ -4118,6 +4152,7 @@
"resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz",
"integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=",
"dev": true,
"optional": true,
"requires": {
"cross-spawn": "^5.0.1",
"get-stream": "^3.0.0",
@ -4196,6 +4231,7 @@
"resolved": "https://registry.npmjs.org/ext-list/-/ext-list-2.2.2.tgz",
"integrity": "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==",
"dev": true,
"optional": true,
"requires": {
"mime-db": "^1.28.0"
}
@ -4205,6 +4241,7 @@
"resolved": "https://registry.npmjs.org/ext-name/-/ext-name-5.0.0.tgz",
"integrity": "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ==",
"dev": true,
"optional": true,
"requires": {
"ext-list": "^2.0.0",
"sort-keys-length": "^1.0.0"
@ -4349,6 +4386,7 @@
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
"dev": true,
"optional": true,
"requires": {
"pend": "~1.2.0"
}
@ -4380,13 +4418,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz",
"integrity": "sha1-q/c9+rc10EVECr/qLZHzieu/oik=",
"dev": true
"dev": true,
"optional": true
},
"filenamify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-2.1.0.tgz",
"integrity": "sha512-ICw7NTT6RsDp2rnYKVd8Fu4cr6ITzGy3+u4vUujPkabyaz+03F24NWEX7fs5fp+kBonlaqPH8fAO2NM+SXt/JA==",
"dev": true,
"optional": true,
"requires": {
"filename-reserved-regex": "^2.0.0",
"strip-outer": "^1.0.0",
@ -4572,7 +4612,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true
"dev": true,
"optional": true
},
"fs-mkdirp-stream": {
"version": "1.0.0",
@ -4622,7 +4663,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -4643,12 +4685,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -4663,17 +4707,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -4790,7 +4837,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -4802,6 +4850,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -4816,6 +4865,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -4823,12 +4873,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -4847,6 +4899,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -4927,7 +4980,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -4939,6 +4993,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -5024,7 +5079,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -5060,6 +5116,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -5079,6 +5136,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -5122,12 +5180,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@ -5148,6 +5208,7 @@
"resolved": "https://registry.npmjs.org/get-proxy/-/get-proxy-2.1.0.tgz",
"integrity": "sha512-zmZIaQTWnNQb4R4fJUEp/FC51eZsc6EkErspy3xtIYStaq8EB/hDIWipxsal+E8rz0qD7f2sL/NA9Xee4RInJw==",
"dev": true,
"optional": true,
"requires": {
"npm-conf": "^1.1.0"
}
@ -5156,13 +5217,15 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz",
"integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=",
"dev": true
"dev": true,
"optional": true
},
"get-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz",
"integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=",
"dev": true
"dev": true,
"optional": true
},
"get-value": {
"version": "2.0.6",
@ -5486,7 +5549,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz",
"integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=",
"dev": true
"dev": true,
"optional": true
},
"gulp": {
"version": "4.0.2",
@ -5740,6 +5804,7 @@
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"optional": true,
"requires": {
"function-bind": "^1.1.1"
}
@ -5764,7 +5829,8 @@
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz",
"integrity": "sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==",
"dev": true
"dev": true,
"optional": true
},
"has-symbols": {
"version": "1.0.0",
@ -5777,6 +5843,7 @@
"resolved": "https://registry.npmjs.org/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz",
"integrity": "sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==",
"dev": true,
"optional": true,
"requires": {
"has-symbol-support-x": "^1.4.1"
}
@ -6010,6 +6077,7 @@
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz",
"integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=",
"dev": true,
"optional": true,
"requires": {
"repeating": "^2.0.0"
}
@ -6142,7 +6210,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==",
"dev": true
"dev": true,
"optional": true
},
"is-data-descriptor": {
"version": "0.1.4",
@ -6168,7 +6237,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
"integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=",
"dev": true
"dev": true,
"optional": true
},
"is-descriptor": {
"version": "0.1.6",
@ -6211,6 +6281,7 @@
"resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz",
"integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=",
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -6263,7 +6334,8 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz",
"integrity": "sha1-q5124dtM7VHjXeDHLr7PCfc0zeg=",
"dev": true
"dev": true,
"optional": true
},
"is-negated-glob": {
"version": "1.0.0",
@ -6295,7 +6367,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz",
"integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=",
"dev": true
"dev": true,
"optional": true
},
"is-path-cwd": {
"version": "2.2.0",
@ -6313,7 +6386,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
"dev": true
"dev": true,
"optional": true
},
"is-plain-object": {
"version": "2.0.4",
@ -6336,6 +6410,7 @@
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"dev": true,
"optional": true,
"requires": {
"has": "^1.0.3"
}
@ -6353,7 +6428,8 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz",
"integrity": "sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==",
"dev": true
"dev": true,
"optional": true
},
"is-stream": {
"version": "1.1.0",
@ -6376,6 +6452,7 @@
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
"integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
"dev": true,
"optional": true,
"requires": {
"has-symbols": "^1.0.1"
},
@ -6384,7 +6461,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==",
"dev": true
"dev": true,
"optional": true
}
}
},
@ -6455,6 +6533,7 @@
"resolved": "https://registry.npmjs.org/isurl/-/isurl-1.0.0.tgz",
"integrity": "sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==",
"dev": true,
"optional": true,
"requires": {
"has-to-string-tag-x": "^1.2.0",
"is-object": "^1.0.1"
@ -6758,7 +6837,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
"integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==",
"dev": true
"dev": true,
"optional": true
},
"lpad-align": {
"version": "1.1.2",
@ -6778,6 +6858,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
"integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
"dev": true,
"optional": true,
"requires": {
"pseudomap": "^1.0.2",
"yallist": "^2.1.2"
@ -6834,7 +6915,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
"integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
"dev": true
"dev": true,
"optional": true
},
"map-visit": {
"version": "1.0.0",
@ -6886,7 +6968,8 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==",
"dev": true
"dev": true,
"optional": true
},
"mem": {
"version": "4.3.0",
@ -6988,7 +7071,8 @@
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.42.0.tgz",
"integrity": "sha512-UbfJCR4UAVRNgMpfImz05smAXK7+c+ZntjaA26ANtkXLlOe947Aag5zdIcKQULAiF9Cq4WxBi9jUs5zkA84bYQ==",
"dev": true
"dev": true,
"optional": true
},
"mimic-fn": {
"version": "2.1.0",
@ -7000,7 +7084,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
"dev": true
"dev": true,
"optional": true
},
"minimalistic-assert": {
"version": "1.0.1",
@ -7288,6 +7373,7 @@
"resolved": "https://registry.npmjs.org/npm-conf/-/npm-conf-1.1.3.tgz",
"integrity": "sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==",
"dev": true,
"optional": true,
"requires": {
"config-chain": "^1.1.11",
"pify": "^3.0.0"
@ -7358,7 +7444,8 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==",
"dev": true
"dev": true,
"optional": true
},
"object-keys": {
"version": "1.0.12",
@ -7598,6 +7685,7 @@
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-1.2.1.tgz",
"integrity": "sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=",
"dev": true,
"optional": true,
"requires": {
"p-finally": "^1.0.0"
}
@ -7747,7 +7835,8 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
"dev": true
"dev": true,
"optional": true
},
"picomatch": {
"version": "2.0.7",
@ -7893,7 +7982,8 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
"dev": true
"dev": true,
"optional": true
},
"prr": {
"version": "1.0.1",
@ -7905,7 +7995,8 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
"dev": true
"dev": true,
"optional": true
},
"public-encrypt": {
"version": "4.0.3",
@ -8356,6 +8447,7 @@
"resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz",
"integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=",
"dev": true,
"optional": true,
"requires": {
"is-finite": "^1.0.0"
}
@ -8565,6 +8657,7 @@
"resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.5.tgz",
"integrity": "sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w=",
"dev": true,
"optional": true,
"requires": {
"commander": "~2.8.1"
}
@ -8794,6 +8887,7 @@
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
"integrity": "sha1-RBttTTRnmPG05J6JIK37oOVD+a0=",
"dev": true,
"optional": true,
"requires": {
"is-plain-obj": "^1.0.0"
}
@ -8803,6 +8897,7 @@
"resolved": "https://registry.npmjs.org/sort-keys-length/-/sort-keys-length-1.0.1.tgz",
"integrity": "sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=",
"dev": true,
"optional": true,
"requires": {
"sort-keys": "^1.0.0"
}
@ -9072,6 +9167,7 @@
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz",
"integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==",
"dev": true,
"optional": true,
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1"
@ -9082,6 +9178,7 @@
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz",
"integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==",
"dev": true,
"optional": true,
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1"
@ -9118,6 +9215,7 @@
"resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz",
"integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==",
"dev": true,
"optional": true,
"requires": {
"is-natural-number": "^4.0.1"
}
@ -9143,6 +9241,7 @@
"resolved": "https://registry.npmjs.org/strip-outer/-/strip-outer-1.0.1.tgz",
"integrity": "sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==",
"dev": true,
"optional": true,
"requires": {
"escape-string-regexp": "^1.0.2"
}
@ -9204,6 +9303,7 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz",
"integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==",
"dev": true,
"optional": true,
"requires": {
"bl": "^1.0.0",
"buffer-alloc": "^1.2.0",
@ -9218,13 +9318,15 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz",
"integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=",
"dev": true
"dev": true,
"optional": true
},
"tempfile": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz",
"integrity": "sha1-awRGhWqbERTRhW/8vlCczLCXcmU=",
"dev": true,
"optional": true,
"requires": {
"temp-dir": "^1.0.0",
"uuid": "^3.0.1"
@ -9336,7 +9438,8 @@
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
"dev": true
"dev": true,
"optional": true
},
"through2": {
"version": "2.0.5",
@ -9377,7 +9480,8 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/timed-out/-/timed-out-4.0.1.tgz",
"integrity": "sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=",
"dev": true
"dev": true,
"optional": true
},
"timers-browserify": {
"version": "2.0.11",
@ -9439,7 +9543,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz",
"integrity": "sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==",
"dev": true
"dev": true,
"optional": true
},
"to-fast-properties": {
"version": "2.0.0",
@ -9515,6 +9620,7 @@
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
"integrity": "sha1-42RqLqTokTEr9+rObPsFOAvAHCE=",
"dev": true,
"optional": true,
"requires": {
"escape-string-regexp": "^1.0.2"
}
@ -9600,6 +9706,7 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.0.1"
}
@ -9627,6 +9734,7 @@
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz",
"integrity": "sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg==",
"dev": true,
"optional": true,
"requires": {
"buffer": "^5.2.1",
"through": "^2.3.8"
@ -9829,7 +9937,8 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz",
"integrity": "sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=",
"dev": true
"dev": true,
"optional": true
},
"use": {
"version": "3.1.1",
@ -9867,7 +9976,8 @@
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz",
"integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==",
"dev": true
"dev": true,
"optional": true
},
"v8-compile-cache": {
"version": "2.0.3",
@ -10328,7 +10438,8 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
"dev": true
"dev": true,
"optional": true
},
"yargs": {
"version": "7.1.0",
@ -10381,6 +10492,7 @@
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
"dev": true,
"optional": true,
"requires": {
"buffer-crc32": "~0.2.3",
"fd-slicer": "~1.1.0"

View file

@ -1592,7 +1592,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -1613,12 +1614,14 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -1633,17 +1636,20 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -1760,7 +1766,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -1772,6 +1779,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -1786,6 +1794,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -1793,12 +1802,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -1817,6 +1828,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -1897,7 +1909,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -1909,6 +1922,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -1994,7 +2008,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -2030,6 +2045,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -2049,6 +2065,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -2092,12 +2109,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},

View file

@ -37,7 +37,8 @@ export interface NoteData {
}
export interface EmailPrefData {
digestWeekly: boolean;
inactiveReminder: boolean;
productUpdate: boolean;
}
export interface UserData {

View file

@ -48,6 +48,17 @@ export interface GetEmailPreferenceParams {
token?: string;
}
export interface GetEmailPreferenceResponse {
inactive_reminder: boolean;
product_update: boolean;
}
export interface UpdateEmailPreferenceParams {
token?: string;
inactiveReminder?: boolean;
productUpdate?: boolean;
}
export interface classicPresigninPayload {
key: string;
expiresAt: number;
@ -143,14 +154,33 @@ export default function init(config: HttpClientConfig) {
return client.patch('/verify-email', payload);
},
updateEmailPreference: ({ token, digestFrequency }) => {
const payload = { digest_weekly: digestFrequency === 'weekly' };
updateEmailPreference: ({
token,
inactiveReminder,
productUpdate
}: UpdateEmailPreferenceParams): Promise<EmailPrefData> => {
const payload: any = {};
if (inactiveReminder !== undefined) {
payload.inactive_reminder = inactiveReminder;
}
if (productUpdate !== undefined) {
payload.product_update = productUpdate;
}
let endpoint = '/account/email-preference';
if (token) {
endpoint = `${endpoint}?token=${token}`;
}
return client.patch(endpoint, payload);
return client
.patch<GetEmailPreferenceResponse>(endpoint, payload)
.then(res => {
return {
inactiveReminder: res.inactive_reminder,
productUpdate: res.product_update
};
});
},
getEmailPreference: ({
@ -161,7 +191,12 @@ export default function init(config: HttpClientConfig) {
endpoint = `${endpoint}?token=${token}`;
}
return client.get<EmailPrefData>(endpoint);
return client.get<GetEmailPreferenceResponse>(endpoint).then(res => {
return {
inactiveReminder: res.inactive_reminder,
productUpdate: res.product_update
};
});
},
getMe: (): Promise<UserData> => {

View file

@ -2,7 +2,7 @@
A simple command line interface for Dnote.
![Dnote](assets/cli.gif)
![Dnote](/assets/cli.gif)
It is Designed to minimize environment switching for taking notes.
@ -30,4 +30,4 @@ Otherwise, you can download the binary for your platform manually from the [rele
## Commands
Please refer to [commands](/COMMANDS.md).
Please refer to [commands](./COMMANDS.md).

View file

@ -39,21 +39,26 @@ var (
ErrEmptyEmailBackend = errors.New("No EmailBackend was provided")
)
// App is an application configuration
type App struct {
DB *gorm.DB
Clock clock.Clock
StripeAPIBackend stripe.Backend
EmailTemplates mailer.Templates
EmailBackend mailer.Backend
// Config is an application configuration
type Config struct {
WebURL string
OnPremise bool
DisableRegistration bool
}
// App is an application context
type App struct {
DB *gorm.DB
Clock clock.Clock
StripeAPIBackend stripe.Backend
EmailTemplates mailer.Templates
EmailBackend mailer.Backend
Config Config
}
// Validate validates the app configuration
func (a *App) Validate() error {
if a.WebURL == "" {
if a.Config.WebURL == "" {
return ErrEmptyWebURL
}
if a.Clock == nil {

View file

@ -42,7 +42,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: Config{
WebURL: "http://mock.url",
},
},
expectedErr: nil,
},
@ -53,7 +55,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: Config{
WebURL: "http://mock.url",
},
},
expectedErr: ErrEmptyDB,
},
@ -64,7 +68,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: Config{
WebURL: "http://mock.url",
},
},
expectedErr: ErrEmptyClock,
},
@ -75,7 +81,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: nil,
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: Config{
WebURL: "http://mock.url",
},
},
expectedErr: ErrEmptyEmailTemplates,
},
@ -86,7 +94,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: nil,
WebURL: "http://mock.url",
Config: Config{
WebURL: "http://mock.url",
},
},
expectedErr: ErrEmptyEmailBackend,
},
@ -97,7 +107,9 @@ func TestValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "",
Config: Config{
WebURL: "",
},
},
expectedErr: ErrEmptyWebURL,
},

View file

@ -29,13 +29,13 @@ import (
var defaultSender = "sung@getdnote.com"
// getSenderEmail returns the sender email
func (a *App) getSenderEmail(want string) (string, error) {
if !a.OnPremise {
// GetSenderEmail returns the sender email
func (c Config) GetSenderEmail(want string) (string, error) {
if !c.OnPremise {
return want, nil
}
addr, err := a.getNoreplySender()
addr, err := getNoreplySender(c)
if err != nil {
return "", errors.Wrap(err, "getting sender email address")
}
@ -59,8 +59,8 @@ func getDomainFromURL(rawURL string) (string, error) {
return domain, nil
}
func (a *App) getNoreplySender() (string, error) {
domain, err := getDomainFromURL(a.WebURL)
func getNoreplySender(c Config) (string, error) {
domain, err := getDomainFromURL(c.WebURL)
if err != nil {
return "", errors.Wrap(err, "parsing web url")
}
@ -73,18 +73,18 @@ func (a *App) getNoreplySender() (string, error) {
func (a *App) SendVerificationEmail(email, tokenValue string) error {
body, err := a.EmailTemplates.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, mailer.EmailVerificationTmplData{
Token: tokenValue,
WebURL: a.WebURL,
WebURL: a.Config.WebURL,
})
if err != nil {
return errors.Wrapf(err, "executing reset verification template for %s", email)
}
from, err := a.getSenderEmail(defaultSender)
from, err := a.Config.GetSenderEmail(defaultSender)
if err != nil {
return errors.Wrap(err, "getting the sender email")
}
if err := a.EmailBackend.Queue("Verify your Dnote email address", from, []string{email}, "text/plain", body); err != nil {
if err := a.EmailBackend.Queue("Verify your Dnote email address", from, []string{email}, mailer.EmailKindText, body); err != nil {
return errors.Wrapf(err, "queueing email for %s", email)
}
@ -95,41 +95,85 @@ func (a *App) SendVerificationEmail(email, tokenValue string) error {
func (a *App) SendWelcomeEmail(email string) error {
body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{
AccountEmail: email,
WebURL: a.WebURL,
WebURL: a.Config.WebURL,
})
if err != nil {
return errors.Wrapf(err, "executing reset verification template for %s", email)
}
from, err := a.getSenderEmail(defaultSender)
from, err := a.Config.GetSenderEmail(defaultSender)
if err != nil {
return errors.Wrap(err, "getting the sender email")
}
if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, "text/plain", body); err != nil {
if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, mailer.EmailKindText, body); err != nil {
return errors.Wrapf(err, "queueing email for %s", email)
}
return nil
}
// SendPasswordResetEmail sends verification email
// SendPasswordResetEmail sends password reset email
func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{
AccountEmail: email,
Token: tokenValue,
WebURL: a.WebURL,
WebURL: a.Config.WebURL,
})
if err != nil {
return errors.Wrapf(err, "executing reset verification template for %s", email)
return errors.Wrapf(err, "executing reset password template for %s", email)
}
from, err := a.getSenderEmail(defaultSender)
from, err := a.Config.GetSenderEmail(defaultSender)
if err != nil {
return errors.Wrap(err, "getting the sender email")
}
if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, "text/plain", body); err != nil {
if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, mailer.EmailKindText, body); err != nil {
return errors.Wrapf(err, "queueing email for %s", email)
}
return nil
}
// SendPasswordResetAlertEmail sends email that notifies users of a password change
func (a *App) SendPasswordResetAlertEmail(email string) error {
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPasswordAlert, mailer.EmailKindText, mailer.EmailResetPasswordAlertTmplData{
AccountEmail: email,
WebURL: a.Config.WebURL,
})
if err != nil {
return errors.Wrapf(err, "executing reset password alert template for %s", email)
}
from, err := a.Config.GetSenderEmail(defaultSender)
if err != nil {
return errors.Wrap(err, "getting the sender email")
}
if err := a.EmailBackend.Queue("Dnote password changed", from, []string{email}, mailer.EmailKindText, body); err != nil {
return errors.Wrapf(err, "queueing email for %s", email)
}
return nil
}
// SendSubscriptionConfirmationEmail sends email that confirms subscription purchase
func (a *App) SendSubscriptionConfirmationEmail(email string) error {
body, err := a.EmailTemplates.Execute(mailer.EmailTypeSubscriptionConfirmation, mailer.EmailKindText, mailer.EmailTypeSubscriptionConfirmationTmplData{
AccountEmail: email,
WebURL: a.Config.WebURL,
})
if err != nil {
return errors.Wrapf(err, "executing subscription confirmation template for %s", email)
}
from, err := a.Config.GetSenderEmail(defaultSender)
if err != nil {
return errors.Wrap(err, "getting the sender email")
}
if err := a.EmailBackend.Queue("Welcome to Dnote Pro", from, []string{email}, mailer.EmailKindText, body); err != nil {
return errors.Wrapf(err, "queueing email for %s", email)
}

View file

@ -28,15 +28,15 @@ import (
func TestSendVerificationEmail(t *testing.T) {
testCases := []struct {
onPremise bool
onPremise bool
expectedSender string
}{
{
onPremise: false,
onPremise: false,
expectedSender: "sung@getdnote.com",
},
{
onPremise: true,
onPremise: true,
expectedSender: "noreply@example.com",
},
}
@ -45,9 +45,11 @@ func TestSendVerificationEmail(t *testing.T) {
t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) {
emailBackend := testutils.MockEmailbackendImplementation{}
a := NewTest(&App{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
EmailBackend: &emailBackend,
Config: Config{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
},
})
if err := a.SendVerificationEmail("alice@example.com", "mockTokenValue"); err != nil {
@ -63,15 +65,15 @@ func TestSendVerificationEmail(t *testing.T) {
func TestSendWelcomeEmail(t *testing.T) {
testCases := []struct {
onPremise bool
onPremise bool
expectedSender string
}{
{
onPremise: false,
onPremise: false,
expectedSender: "sung@getdnote.com",
},
{
onPremise: true,
onPremise: true,
expectedSender: "noreply@example.com",
},
}
@ -80,9 +82,11 @@ func TestSendWelcomeEmail(t *testing.T) {
t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) {
emailBackend := testutils.MockEmailbackendImplementation{}
a := NewTest(&App{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
EmailBackend: &emailBackend,
Config: Config{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
},
})
if err := a.SendWelcomeEmail("alice@example.com"); err != nil {
@ -98,15 +102,15 @@ func TestSendWelcomeEmail(t *testing.T) {
func TestSendPasswordResetEmail(t *testing.T) {
testCases := []struct {
onPremise bool
onPremise bool
expectedSender string
}{
{
onPremise: false,
onPremise: false,
expectedSender: "sung@getdnote.com",
},
{
onPremise: true,
onPremise: true,
expectedSender: "noreply@example.com",
},
}
@ -115,9 +119,11 @@ func TestSendPasswordResetEmail(t *testing.T) {
t.Run(fmt.Sprintf("self hosted %t", tc.onPremise), func(t *testing.T) {
emailBackend := testutils.MockEmailbackendImplementation{}
a := NewTest(&App{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
EmailBackend: &emailBackend,
Config: Config{
OnPremise: tc.onPremise,
WebURL: "http://example.com",
},
})
if err := a.SendPasswordResetEmail("alice@example.com", "mockTokenValue"); err != nil {
@ -130,3 +136,60 @@ func TestSendPasswordResetEmail(t *testing.T) {
})
}
}
func TestSendSubscriptionConfirmationEmail(t *testing.T) {
emailBackend := testutils.MockEmailbackendImplementation{}
a := NewTest(&App{
EmailBackend: &emailBackend,
Config: Config{
OnPremise: false,
WebURL: "http://example.com",
},
})
if err := a.SendSubscriptionConfirmationEmail("alice@example.com"); err != nil {
t.Fatal(err, "failed to perform")
}
assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch")
assert.Equal(t, emailBackend.Emails[0].From, "sung@getdnote.com", "email sender mismatch")
assert.DeepEqual(t, emailBackend.Emails[0].To, []string{"alice@example.com"}, "email sender mismatch")
}
func TestGetSenderEmail(t *testing.T) {
testCases := []struct {
onPremise bool
webURL string
candidate string
expectedSender string
}{
{
onPremise: true,
webURL: "https://www.example.com",
candidate: "alice@getdnote.com",
expectedSender: "noreply@example.com",
},
{
onPremise: false,
webURL: "https://www.getdnote.com",
candidate: "alice@getdnote.com",
expectedSender: "alice@getdnote.com",
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("on premise %t candidate %s", tc.onPremise, tc.candidate), func(t *testing.T) {
c := Config{
OnPremise: tc.onPremise,
WebURL: tc.webURL,
}
got, err := c.GetSenderEmail(tc.candidate)
if err != nil {
t.Fatal(err, "failed to perform")
}
assert.Equal(t, got, tc.expectedSender, "result mismatch")
})
}
}

View file

@ -32,12 +32,14 @@ func NewTest(appParams *App) App {
a := App{
DB: testutils.DB,
WebURL: os.Getenv("WebURL"),
Clock: clock.NewMock(),
EmailTemplates: mailer.NewTemplates(&emailTmplDir),
EmailBackend: &testutils.MockEmailbackendImplementation{},
StripeAPIBackend: nil,
OnPremise: false,
Config: Config{
OnPremise: false,
WebURL: os.Getenv("WebURL"),
},
}
// Allow to override with appParams
@ -53,14 +55,14 @@ func NewTest(appParams *App) App {
if appParams != nil && appParams.StripeAPIBackend != nil {
a.StripeAPIBackend = appParams.StripeAPIBackend
}
if appParams != nil && appParams.OnPremise {
a.OnPremise = appParams.OnPremise
if appParams != nil && appParams.Config.OnPremise {
a.Config.OnPremise = appParams.Config.OnPremise
}
if appParams != nil && appParams.WebURL != "" {
a.WebURL = appParams.WebURL
if appParams != nil && appParams.Config.WebURL != "" {
a.Config.WebURL = appParams.Config.WebURL
}
if appParams != nil && appParams.DisableRegistration {
a.DisableRegistration = appParams.DisableRegistration
if appParams != nil && appParams.Config.DisableRegistration {
a.Config.DisableRegistration = appParams.Config.DisableRegistration
}
return a

View file

@ -21,31 +21,13 @@ package app
import (
"time"
"github.com/dnote/dnote/pkg/server/crypt"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/token"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
func generateResetToken() (string, error) {
ret, err := crypt.GetRandomStr(16)
if err != nil {
return "", errors.Wrap(err, "generating random token")
}
return ret, nil
}
func generateVerificationCode() (string, error) {
ret, err := crypt.GetRandomStr(16)
if err != nil {
return "", errors.Wrap(err, "generating random token")
}
return ret, nil
}
// TouchLastLoginAt updates the last login timestamp
func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error {
t := time.Now()
@ -56,23 +38,6 @@ func (a *App) TouchLastLoginAt(user database.User, tx *gorm.DB) error {
return nil
}
func createEmailVerificaitonToken(user database.User, tx *gorm.DB) error {
verificationCode, err := generateVerificationCode()
if err != nil {
return errors.Wrap(err, "generating verification code")
}
token := database.Token{
UserID: user.ID,
Type: database.TokenTypeEmailVerification,
Value: verificationCode,
}
if err := tx.Save(&token).Error; err != nil {
return errors.Wrap(err, "saving verification token")
}
return nil
}
func createEmailPreference(user database.User, tx *gorm.DB) error {
p := database.EmailPreference{
UserID: user.ID,
@ -115,7 +80,7 @@ func (a *App) CreateUser(email, password string) (database.User, error) {
// Grant all privileges if self-hosting
var pro bool
if a.OnPremise {
if a.Config.OnPremise {
pro = true
} else {
pro = false
@ -138,7 +103,7 @@ func (a *App) CreateUser(email, password string) (database.User, error) {
return database.User{}, errors.Wrap(err, "saving account")
}
if err := createEmailVerificaitonToken(user, tx); err != nil {
if _, err := token.Create(tx, user.ID, database.TokenTypeEmailPreference); err != nil {
tx.Rollback()
return database.User{}, errors.Wrap(err, "creating email verificaiton token")
}

View file

@ -30,15 +30,15 @@ import (
func TestCreateUser(t *testing.T) {
testCases := []struct {
onPremise bool
onPremise bool
expectedPro bool
}{
{
onPremise: true,
onPremise: true,
expectedPro: true,
},
{
onPremise: false,
onPremise: false,
expectedPro: false,
},
}
@ -48,7 +48,9 @@ func TestCreateUser(t *testing.T) {
defer testutils.ClearData()
a := NewTest(&App{
OnPremise: tc.onPremise,
Config: Config{
OnPremise: tc.onPremise,
},
})
if _, err := a.CreateUser("alice@example.com", "pass1234"); err != nil {
t.Fatal(errors.Wrap(err, "executing"))

View file

@ -109,11 +109,12 @@ type Notification struct {
UserID int `gorm:"index"`
}
// EmailPreference is information about how often user wants to receive digest email
// EmailPreference is a preference per user for receiving email communication
type EmailPreference struct {
Model
UserID int `gorm:"index" json:"-"`
DigestWeekly bool `json:"digest_weekly"` // Deprecated: email digests now sends based on the repetition rule
UserID int `gorm:"index" json:"-"`
InactiveReminder bool `json:"inactive_reminder" gorm:"default:true"`
ProductUpdate bool `json:"product_update" gorm:"default:true"`
}
// Session represents a user session

View file

@ -20,13 +20,14 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/token"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
)
@ -77,7 +78,7 @@ func (a *API) getMe(w http.ResponseWriter, r *http.Request) {
if err := a.App.TouchLastLoginAt(user, tx); err != nil {
tx.Rollback()
// In case of an error, gracefully continue to avoid disturbing the service
log.Println("error touching last_login_at", err.Error())
log.ErrorWrap(err, "error touching last_login_at")
}
tx.Commit()
@ -110,24 +111,13 @@ func (a *API) createResetToken(w http.ResponseWriter, r *http.Request) {
return
}
resetToken, err := generateResetToken()
resetToken, err := token.Create(a.App.DB, account.UserID, database.TokenTypeResetPassword)
if err != nil {
HandleError(w, errors.Wrap(err, "generating token").Error(), nil, http.StatusInternalServerError)
return
}
token := database.Token{
UserID: account.UserID,
Value: resetToken,
Type: database.TokenTypeResetPassword,
}
if err := a.App.DB.Save(&token).Error; err != nil {
HandleError(w, errors.Wrap(err, "saving token").Error(), nil, http.StatusInternalServerError)
return
}
if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken); err != nil {
if err := a.App.SendPasswordResetEmail(account.Email.String, resetToken.Value); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
respondInvalidSMTPConfig(w)
} else {
@ -208,4 +198,8 @@ func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) {
}
a.respondWithSession(a.App.DB, w, user.ID, http.StatusOK)
if err := a.App.SendPasswordResetAlertEmail(account.Email.String); err != nil {
log.ErrorWrap(err, "sending password reset email")
}
}

View file

@ -19,8 +19,6 @@
package handlers
import (
crand "crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
@ -31,39 +29,6 @@ import (
"github.com/pkg/errors"
)
const (
demoUserEmail = "demo@dnote.io"
)
func generateRandomToken(bits int) (string, error) {
b := make([]byte, bits)
_, err := crand.Read(b)
if err != nil {
return "", errors.Wrap(err, "generating random bytes")
}
return base64.URLEncoding.EncodeToString(b), nil
}
func generateResetToken() (string, error) {
ret, err := generateRandomToken(16)
if err != nil {
return "", errors.Wrap(err, "generating random token")
}
return ret, nil
}
func generateVerificationCode() (string, error) {
ret, err := generateRandomToken(16)
if err != nil {
return "", errors.Wrap(err, "generating random token")
}
return ret, nil
}
func paginate(conn *gorm.DB, page int) *gorm.DB {
limit := 30

View file

@ -702,7 +702,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: app.Config{
WebURL: "http://mock.url",
},
},
expectedErr: nil,
},
@ -713,7 +715,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: app.Config{
WebURL: "http://mock.url",
},
},
expectedErr: app.ErrEmptyDB,
},
@ -724,7 +728,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: app.Config{
WebURL: "http://mock.url",
},
},
expectedErr: app.ErrEmptyClock,
},
@ -735,7 +741,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: nil,
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "http://mock.url",
Config: app.Config{
WebURL: "http://mock.url",
},
},
expectedErr: app.ErrEmptyEmailTemplates,
},
@ -746,7 +754,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: nil,
WebURL: "http://mock.url",
Config: app.Config{
WebURL: "http://mock.url",
},
},
expectedErr: app.ErrEmptyEmailBackend,
},
@ -757,7 +767,9 @@ func TestNewRouter_AppValidate(t *testing.T) {
StripeAPIBackend: nil,
EmailTemplates: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
WebURL: "",
Config: app.Config{
WebURL: "",
},
},
expectedErr: app.ErrEmptyWebURL,
},

View file

@ -29,6 +29,7 @@ import (
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/helpers"
"github.com/dnote/dnote/pkg/server/log"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"github.com/stripe/stripe-go"
@ -131,6 +132,11 @@ func (a *API) createSub(w http.ResponseWriter, r *http.Request) {
HandleError(w, "No authenticated user found", nil, http.StatusInternalServerError)
return
}
var account database.Account
if err := a.App.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
HandleError(w, "getting user", err, http.StatusInternalServerError)
return
}
var payload createSubPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
@ -174,6 +180,10 @@ func (a *API) createSub(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.App.SendSubscriptionConfirmationEmail(account.Email.String); err != nil {
log.ErrorWrap(err, "sending subscription confirmation email")
}
w.WriteHeader(http.StatusOK)
}

View file

@ -28,6 +28,7 @@ import (
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/presenters"
"github.com/dnote/dnote/pkg/server/token"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
@ -155,24 +156,13 @@ func (a *API) createVerificationToken(w http.ResponseWriter, r *http.Request) {
return
}
tokenValue, err := generateVerificationCode()
tok, err := token.Create(a.App.DB, account.UserID, database.TokenTypeEmailVerification)
if err != nil {
HandleError(w, "generating verification code", err, http.StatusInternalServerError)
return
}
token := database.Token{
UserID: account.UserID,
Value: tokenValue,
Type: database.TokenTypeEmailVerification,
}
if err := a.App.DB.Save(&token).Error; err != nil {
HandleError(w, "saving token", err, http.StatusInternalServerError)
return
}
if err := a.App.SendVerificationEmail(account.Email.String, tokenValue); err != nil {
if err := a.App.SendVerificationEmail(account.Email.String, tok.Value); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
respondInvalidSMTPConfig(w)
} else {
@ -249,8 +239,25 @@ func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, session)
}
type updateEmailPreferencePayload struct {
DigestWeekly bool `json:"digest_weekly"`
type emailPreferernceParams struct {
InactiveReminder *bool `json:"inactive_reminder"`
ProductUpdate *bool `json:"product_update"`
}
func (p emailPreferernceParams) getInactiveReminder() bool {
if p.InactiveReminder == nil {
return false
}
return *p.InactiveReminder
}
func (p emailPreferernceParams) getProductUpdate() bool {
if p.ProductUpdate == nil {
return false
}
return *p.ProductUpdate
}
func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
@ -260,30 +267,36 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
return
}
var params updateEmailPreferencePayload
var params emailPreferernceParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
HandleError(w, "decoding payload", err, http.StatusInternalServerError)
return
}
var frequency database.EmailPreference
if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&frequency).Error; err != nil {
HandleError(w, "finding frequency", err, http.StatusInternalServerError)
var pref database.EmailPreference
if err := a.App.DB.Where(database.EmailPreference{UserID: user.ID}).FirstOrCreate(&pref).Error; err != nil {
HandleError(w, "finding pref", err, http.StatusInternalServerError)
return
}
tx := a.App.DB.Begin()
frequency.DigestWeekly = params.DigestWeekly
if err := tx.Save(&frequency).Error; err != nil {
if params.InactiveReminder != nil {
pref.InactiveReminder = params.getInactiveReminder()
}
if params.ProductUpdate != nil {
pref.ProductUpdate = params.getProductUpdate()
}
if err := tx.Save(&pref).Error; err != nil {
tx.Rollback()
HandleError(w, "saving frequency", err, http.StatusInternalServerError)
HandleError(w, "saving pref", err, http.StatusInternalServerError)
return
}
token, ok := r.Context().Value(helpers.KeyToken).(database.Token)
if ok {
// Use token if the user was authenticated by token
// Mark token as used if the user was authenticated by token
if err := tx.Model(&token).Update("used_at", time.Now()).Error; err != nil {
tx.Rollback()
HandleError(w, "updating reset token", err, http.StatusInternalServerError)
@ -293,7 +306,7 @@ func (a *API) updateEmailPreference(w http.ResponseWriter, r *http.Request) {
tx.Commit()
respondJSON(w, http.StatusOK, frequency)
respondJSON(w, http.StatusOK, pref)
}
func (a *API) getEmailPreference(w http.ResponseWriter, r *http.Request) {

View file

@ -362,7 +362,6 @@ func TestVerifyEmail(t *testing.T) {
func TestUpdateEmail(t *testing.T) {
t.Run("success", func(t *testing.T) {
defer testutils.ClearData()
// Setup
@ -396,7 +395,6 @@ func TestUpdateEmail(t *testing.T) {
func TestUpdateEmailPreference(t *testing.T) {
t.Run("with login", func(t *testing.T) {
defer testutils.ClearData()
// Setup
@ -409,7 +407,7 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.SetupEmailPreferenceData(u, false)
// Execute
dat := `{"digest_weekly": true}`
dat := `{"inactive_reminder": true}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
res := testutils.HTTPAuthDo(t, req, u)
@ -418,16 +416,14 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding account")
assert.Equal(t, preference.DigestWeekly, true, "preference mismatch")
assert.Equal(t, preference.InactiveReminder, true, "preference mismatch")
})
t.Run("with an unused token", func(t *testing.T) {
defer testutils.ClearData()
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
@ -442,7 +438,7 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"digest_weekly": true}`
dat := `{"inactive_reminder": true}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
@ -458,17 +454,15 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.MustExec(t, testutils.DB.Where("id = ?", tok.ID).First(&token), "failed to find token")
assert.Equal(t, preferenceCount, 1, "preference count mismatch")
assert.Equal(t, preference.DigestWeekly, true, "email mismatch")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
assert.NotEqual(t, token.UsedAt, (*time.Time)(nil), "token should have been used")
})
t.Run("with nonexistent token", func(t *testing.T) {
defer testutils.ClearData()
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
})
defer server.Close()
@ -482,7 +476,7 @@ func TestUpdateEmailPreference(t *testing.T) {
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"digest_weekly": false}`
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someNonexistentToken")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
@ -494,11 +488,10 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.DigestWeekly, true, "email mismatch")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("with expired token", func(t *testing.T) {
defer testutils.ClearData()
// Setup
@ -521,7 +514,7 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"digest_weekly": false}`
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
@ -531,7 +524,7 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.DigestWeekly, true, "email mismatch")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("with a used but unexpired token", func(t *testing.T) {
@ -556,7 +549,7 @@ func TestUpdateEmailPreference(t *testing.T) {
}
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
dat := `{"digest_weekly": false}`
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
@ -568,7 +561,7 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.DigestWeekly, false, "DigestWeekly mismatch")
assert.Equal(t, preference.InactiveReminder, false, "InactiveReminder mismatch")
})
t.Run("no user and no token", func(t *testing.T) {
@ -586,7 +579,7 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.SetupEmailPreferenceData(u, true)
// Execute
dat := `{"digest_weekly": false}`
dat := `{"inactive_reminder": false}`
req := testutils.MakeReq(server.URL, "PATCH", "/account/email-preference", dat)
res := testutils.HTTPDo(t, req)
@ -595,7 +588,7 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.DigestWeekly, true, "email mismatch")
assert.Equal(t, preference.InactiveReminder, true, "email mismatch")
})
t.Run("create a record if not exists", func(t *testing.T) {
@ -618,7 +611,7 @@ func TestUpdateEmailPreference(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&tok), "preparing token")
// Execute
dat := `{"digest_weekly": false}`
dat := `{"inactive_reminder": false}`
url := fmt.Sprintf("/account/email-preference?token=%s", "someTokenValue")
req := testutils.MakeReq(server.URL, "PATCH", url, dat)
res := testutils.HTTPDo(t, req)
@ -632,7 +625,7 @@ func TestUpdateEmailPreference(t *testing.T) {
var preference database.EmailPreference
testutils.MustExec(t, testutils.DB.Where("user_id = ?", u.ID).First(&preference), "finding preference")
assert.Equal(t, preference.DigestWeekly, false, "email mismatch")
assert.Equal(t, preference.InactiveReminder, false, "email mismatch")
})
}
@ -661,9 +654,10 @@ func TestGetEmailPreference(t *testing.T) {
}
expected := presenters.EmailPreference{
DigestWeekly: pref.DigestWeekly,
CreatedAt: presenters.FormatTS(pref.CreatedAt),
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
InactiveReminder: pref.InactiveReminder,
ProductUpdate: pref.ProductUpdate,
CreatedAt: presenters.FormatTS(pref.CreatedAt),
UpdatedAt: presenters.FormatTS(pref.UpdatedAt),
}
assert.DeepEqual(t, got, expected, "payload mismatch")
}

View file

@ -176,7 +176,7 @@ func parseRegisterPaylaod(r *http.Request) (registerPayload, error) {
}
func (a *API) register(w http.ResponseWriter, r *http.Request) {
if a.App.DisableRegistration {
if a.App.Config.DisableRegistration {
respondForbidden(w)
return
}

View file

@ -100,7 +100,9 @@ func TestRegister(t *testing.T) {
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
EmailBackend: &emailBackend,
OnPremise: tc.onPremise,
Config: app.Config{
OnPremise: tc.onPremise,
},
})
defer server.Close()
@ -235,8 +237,10 @@ func TestRegisterDisabled(t *testing.T) {
// Setup
server := MustNewServer(t, &app.App{
Clock: clock.NewMock(),
DisableRegistration: true,
Clock: clock.NewMock(),
Config: app.Config{
DisableRegistration: true,
},
})
defer server.Close()

View file

@ -20,27 +20,9 @@ package helpers
import (
"github.com/google/uuid"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
const (
demoUserEmail = "demo@dnote.io"
)
// GetDemoUserID returns ID of the demo user
func GetDemoUserID(db *gorm.DB) (int, error) {
result := struct {
UserID int
}{}
if err := db.Table("accounts").Select("user_id").Where("email = ?", demoUserEmail).Scan(&result).Error; err != nil {
return result.UserID, errors.Wrap(err, "finding demo user")
}
return result.UserID, nil
}
// GenUUID generates a new uuid v4
func GenUUID() (string, error) {
id, err := uuid.NewRandom()

View file

@ -19,10 +19,13 @@
package job
import (
"log"
slog "log"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/job/remind"
"github.com/dnote/dnote/pkg/server/job/repetition"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
@ -48,17 +51,17 @@ type Runner struct {
Clock clock.Clock
EmailTmpl mailer.Templates
EmailBackend mailer.Backend
WebURL string
Config app.Config
}
// NewRunner returns a new runner
func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, webURL string) (Runner, error) {
func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, config app.Config) (Runner, error) {
ret := Runner{
DB: db,
EmailTmpl: t,
EmailBackend: b,
Clock: c,
WebURL: webURL,
Config: config,
}
if err := ret.validate(); err != nil {
@ -81,7 +84,7 @@ func (r *Runner) validate() error {
if r.EmailBackend == nil {
return ErrEmptyEmailBackend
}
if r.WebURL == "" {
if r.Config.WebURL == "" {
return ErrEmptyWebURL
}
@ -101,6 +104,7 @@ func (r *Runner) schedule(ch chan error) {
// Schedule jobs
cr := cron.New()
scheduleJob(cr, "* * * * *", func() { r.DoRepetition() })
scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() })
cr.Start()
ch <- nil
@ -122,22 +126,53 @@ func (r *Runner) Do() error {
return errors.Wrap(err, "scheduling jobs")
}
log.Println("Started background tasks")
slog.Println("Started background tasks")
return nil
}
// DoRepetition creates spaced repetitions and delivers the results based on the rules
func (r *Runner) DoRepetition() error {
p := repetition.Params{
func (r *Runner) DoRepetition() {
c := repetition.Context{
DB: r.DB,
Clock: r.Clock,
EmailTmpl: r.EmailTmpl,
EmailBackend: r.EmailBackend,
}
if err := repetition.Do(p); err != nil {
return errors.Wrap(err, "performing repetition job")
Config: r.Config,
}
return nil
result, err := repetition.Do(c)
m := log.WithFields(log.Fields{
"success_count": result.SuccessCount,
"failed_rule_uuids": result.FailedRuleUUIDs,
})
if err == nil {
m.Info("successfully processed repetition job")
} else {
m.ErrorWrap(err, "error processing repetition job")
}
}
// RemindNoRecentNotes remind users if no notes have been added recently
func (r *Runner) RemindNoRecentNotes() {
c := remind.Context{
DB: r.DB,
Clock: r.Clock,
EmailTmpl: r.EmailTmpl,
EmailBackend: r.EmailBackend,
Config: r.Config,
}
result, err := remind.DoInactive(c)
m := log.WithFields(log.Fields{
"success_count": result.SuccessCount,
"failed_user_ids": result.FailedUserIDs,
})
if err == nil {
m.Info("successfully processed no recent note reminder job")
} else {
m.ErrorWrap(err, "error processing no recent note reminder job")
}
}

View file

@ -24,6 +24,7 @@ import (
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/jinzhu/gorm"
@ -91,7 +92,9 @@ func TestNewRunner(t *testing.T) {
for idx, tc := range testCases {
t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) {
_, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, tc.webURL)
_, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, app.Config{
WebURL: tc.webURL,
})
assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch")
})

View file

@ -0,0 +1,204 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
// Context holds data that repetition job needs in order to perform
type Context struct {
DB *gorm.DB
Clock clock.Clock
EmailTmpl mailer.Templates
EmailBackend mailer.Backend
Config app.Config
}
type inactiveUserInfo struct {
userID int
email string
sampleNoteUUID string
}
func (c *Context) sampleUserNote(userID int) (database.Note, error) {
var ret database.Note
// FIXME: ordering by random() requires a sequential scan on the whole table and does not scale
if err := c.DB.Where("user_id = ?", userID).Order("random() DESC").First(&ret).Error; err != nil {
return ret, errors.Wrap(err, "getting a random note")
}
return ret, nil
}
func (c *Context) getInactiveUserInfo() ([]inactiveUserInfo, error) {
ret := []inactiveUserInfo{}
threshold := c.Clock.Now().AddDate(0, 0, -14).Unix()
rows, err := c.DB.Raw(`
SELECT
notes.user_id AS user_id,
accounts.email,
SUM(
CASE
WHEN notes.created_at > to_timestamp(?) THEN 1
ELSE 0
END
) AS recent_note_count,
COUNT(*) AS total_note_count
FROM notes
INNER JOIN accounts ON accounts.user_id = notes.user_id
WHERE accounts.email IS NOT NULL AND accounts.email_verified IS TRUE
GROUP BY notes.user_id, accounts.email`, threshold).Rows()
if err != nil {
return ret, errors.Wrap(err, "executing note count SQL query")
}
defer rows.Close()
for rows.Next() {
var userID, recentNoteCount, totalNoteCount int
var email string
if err := rows.Scan(&userID, &email, &recentNoteCount, &totalNoteCount); err != nil {
return nil, errors.Wrap(err, "scanning a row")
}
if recentNoteCount == 0 && totalNoteCount > 0 {
note, err := c.sampleUserNote(userID)
if err != nil {
return nil, errors.Wrap(err, "sampling user note")
}
ret = append(ret, inactiveUserInfo{
userID: userID,
email: email,
sampleNoteUUID: note.UUID,
})
}
}
return ret, nil
}
func (c *Context) canNotify(info inactiveUserInfo) (bool, error) {
var pref database.EmailPreference
if err := c.DB.Where("user_id = ?", info.userID).First(&pref).Error; err != nil {
return false, errors.Wrap(err, "getting email preference")
}
if !pref.InactiveReminder {
return false, nil
}
var notif database.Notification
conn := c.DB.Where("user_id = ? AND type = ?", info.userID, mailer.EmailTypeInactiveReminder).Order("created_at DESC").First(&notif)
if conn.RecordNotFound() {
return true, nil
} else if err := conn.Error; err != nil {
return false, errors.Wrap(err, "checking cooldown")
}
t := c.Clock.Now().AddDate(0, 0, -14)
if notif.CreatedAt.Before(t) {
return true, nil
}
return false, nil
}
func (c *Context) process(info inactiveUserInfo) error {
ok, err := c.canNotify(info)
if err != nil {
return errors.Wrap(err, "checking if user can be notified")
}
if !ok {
return nil
}
sender, err := c.Config.GetSenderEmail("noreply@getdnote.com")
if err != nil {
return errors.Wrap(err, "getting sender email")
}
tmplData := mailer.InactiveReminderTmplData{
WebURL: c.Config.WebURL,
SampleNoteUUID: info.sampleNoteUUID,
Token: "blah",
}
body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData)
if err != nil {
return errors.Wrap(err, "executing inactive email template")
}
if err := c.EmailBackend.Queue("Your knowledge base stopped growing", sender, []string{info.email}, mailer.EmailKindHTML, body); err != nil {
return errors.Wrap(err, "queueing email")
}
if err := c.DB.Create(&database.Notification{
Type: mailer.EmailTypeInactiveReminder,
UserID: info.userID,
}).Error; err != nil {
return errors.Wrap(err, "creating notification")
}
return nil
}
// Result holds the result of the job
type Result struct {
SuccessCount int
FailedUserIDs []int
}
// DoInactive sends reminder for users with no recent notes
func DoInactive(c Context) (Result, error) {
log.Info("performing reminder for no recent notes")
result := Result{}
items, err := c.getInactiveUserInfo()
if err != nil {
return result, errors.Wrap(err, "getting inactive user information")
}
log.WithFields(log.Fields{
"user_count": len(items),
}).Info("counted inactive users")
for _, item := range items {
err := c.process(item)
if err == nil {
result.SuccessCount = result.SuccessCount + 1
} else {
log.WithFields(log.Fields{
"user_id": item.userID,
}).ErrorWrap(err, "Could not process no recent notes reminder")
result.FailedUserIDs = append(result.FailedUserIDs, item.userID)
}
}
return result, nil
}

View file

@ -0,0 +1,194 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"os"
"sort"
"testing"
"time"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
con := Context{
DB: testutils.DB,
Clock: c,
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
EmailBackend: be,
}
return con
}
func TestDoInactive(t *testing.T) {
defer testutils.ClearData()
t1 := time.Now()
// u1 is an active user
u1 := testutils.SetupUserData()
a1 := testutils.SetupAccountData(u1, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a1).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u1.ID, InactiveReminder: true}), "preparing email preference")
b1 := database.Book{
UserID: u1.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
n1 := database.Note{
BookUUID: b1.UUID,
UserID: u1.ID,
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
// u2 is an inactive user
u2 := testutils.SetupUserData()
a2 := testutils.SetupAccountData(u2, "bob@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a2).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u2.ID, InactiveReminder: true}), "preparing email preference")
b2 := database.Book{
UserID: u2.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
n2 := database.Note{
UserID: u2.ID,
BookUUID: b2.UUID,
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
testutils.MustExec(t, testutils.DB.Model(&n2).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n2")
// u3 is an inactive user with inactive alert email preference disabled
u3 := testutils.SetupUserData()
a3 := testutils.SetupAccountData(u3, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a3).Update("email_verified", true), "setting email verified")
emailPref3 := database.EmailPreference{UserID: u3.ID}
testutils.MustExec(t, testutils.DB.Save(&emailPref3), "preparing email preference")
testutils.MustExec(t, testutils.DB.Model(&emailPref3).Update(map[string]interface{}{"inactive_reminder": false}), "updating email preference")
b3 := database.Book{
UserID: u3.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
n3 := database.Note{
BookUUID: b3.UUID,
UserID: u3.ID,
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
testutils.MustExec(t, testutils.DB.Model(&n3).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n3")
c := clock.NewMock()
c.SetNow(t1)
be := &testutils.MockEmailbackendImplementation{}
con := getTestContext(c, be)
if _, err := DoInactive(con); err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
assert.Equalf(t, len(be.Emails), 1, "email queue count mismatch")
assert.DeepEqual(t, be.Emails[0].To, []string{a2.Email.String}, "email address mismatch")
}
func TestDoInactive_Cooldown(t *testing.T) {
defer testutils.ClearData()
// setup sets up an inactive user
setup := func(t *testing.T, now time.Time, email string) database.User {
u := testutils.SetupUserData()
a := testutils.SetupAccountData(u, email, "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u.ID, InactiveReminder: true}), "preparing email preference")
b := database.Book{
UserID: u.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b), "preparing book")
n := database.Note{
UserID: u.ID,
BookUUID: b.UUID,
}
testutils.MustExec(t, testutils.DB.Save(&n), "preparing note")
testutils.MustExec(t, testutils.DB.Model(&n).Update("created_at", now.AddDate(0, 0, -15)), "preparing note")
return u
}
// Set up
now := time.Now()
setup(t, now, "alice@example.com")
bob := setup(t, now, "bob@example.com")
bobNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: bob.ID}
testutils.MustExec(t, testutils.DB.Create(&bobNotif), "preparing inactive notification for bob")
testutils.MustExec(t, testutils.DB.Model(&bobNotif).Update("created_at", now.AddDate(0, 0, -7)), "preparing created_at for inactive notification for bob")
chuck := setup(t, now, "chuck@example.com")
chuckNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: chuck.ID}
testutils.MustExec(t, testutils.DB.Create(&chuckNotif), "preparing inactive notification for chuck")
testutils.MustExec(t, testutils.DB.Model(&chuckNotif).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for chuck")
dan := setup(t, now, "dan@example.com")
danNotif1 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
testutils.MustExec(t, testutils.DB.Create(&danNotif1), "preparing inactive notification 1 for dan")
testutils.MustExec(t, testutils.DB.Model(&danNotif1).Update("created_at", now.AddDate(0, 0, -10)), "preparing created_at for inactive notification for dan")
danNotif2 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
testutils.MustExec(t, testutils.DB.Create(&danNotif2), "preparing inactive notification 2 for dan")
testutils.MustExec(t, testutils.DB.Model(&danNotif2).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for dan")
c := clock.NewMock()
c.SetNow(now)
be := &testutils.MockEmailbackendImplementation{}
// Execute
con := getTestContext(c, be)
if _, err := DoInactive(con); err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
// Test
assert.Equalf(t, len(be.Emails), 2, "email queue count mismatch")
var recipients []string
for _, email := range be.Emails {
recipients = append(recipients, email.To[0])
}
sort.SliceStable(recipients, func(i, j int) bool {
r1 := recipients[i]
r2 := recipients[j]
return r1 < r2
})
assert.DeepEqual(t, recipients, []string{"alice@example.com", "chuck@example.com"}, "email address mismatch")
}

View file

@ -0,0 +1,35 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"os"
"testing"
"github.com/dnote/dnote/pkg/server/testutils"
)
func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
os.Exit(code)
}

View file

@ -24,6 +24,7 @@ import (
"time"
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
@ -31,12 +32,13 @@ import (
"github.com/pkg/errors"
)
// Params holds data that repetition job needs in order to perform
type Params struct {
// Context holds data that repetition job needs in order to perform
type Context struct {
DB *gorm.DB
Clock clock.Clock
EmailTmpl mailer.Templates
EmailBackend mailer.Backend
Config app.Config
}
// BuildEmailParams is the params for building an email
@ -93,8 +95,7 @@ func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (st
RuleTitle: p.Rule.Title,
WebURL: os.Getenv("WebURL"),
}
body, err := emailTmpl.Execute(mailer.EmailTypeWeeklyDigest, mailer.EmailKindHTML, tmplData)
body, err := emailTmpl.Execute(mailer.EmailTypeDigest, mailer.EmailKindHTML, tmplData)
if err != nil {
return "", "", errors.Wrap(err, "executing digest email template")
}
@ -102,12 +103,12 @@ func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (st
return subject, body, nil
}
func getEligibleRules(db *gorm.DB, now time.Time) ([]database.RepetitionRule, error) {
func (c Context) getEligibleRules(now time.Time) ([]database.RepetitionRule, error) {
hour := now.Hour()
minute := now.Minute()
var ret []database.RepetitionRule
if err := db.
if err := c.DB.
Where("users.cloud AND repetition_rules.hour = ? AND repetition_rules.minute = ? AND repetition_rules.enabled", hour, minute).
Joins("INNER JOIN users ON users.id = repetition_rules.user_id").
Find(&ret).Error; err != nil {
@ -135,9 +136,9 @@ func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) {
return digest, nil
}
func notify(p Params, now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error {
func (c Context) notify(now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error {
var account database.Account
if err := p.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
if err := c.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil {
return errors.Wrap(err, "getting account")
}
@ -148,7 +149,7 @@ func notify(p Params, now time.Time, user database.User, digest database.Digest,
return nil
}
subject, body, err := BuildEmail(p.DB, p.EmailTmpl, BuildEmailParams{
subject, body, err := BuildEmail(c.DB, c.EmailTmpl, BuildEmailParams{
Now: now,
User: user,
Digest: digest,
@ -158,15 +159,19 @@ func notify(p Params, now time.Time, user database.User, digest database.Digest,
return errors.Wrap(err, "making email")
}
if err := p.EmailBackend.Queue(subject, "noreply@getdnote.com", []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil {
sender, err := c.Config.GetSenderEmail("noreply@getdnote.com")
if err != nil {
return errors.Wrap(err, "getting sender email")
}
if err := c.EmailBackend.Queue(subject, sender, []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil {
return errors.Wrap(err, "queueing email")
}
notif := database.Notification{
Type: "email_weekly",
if err := c.DB.Create(&database.Notification{
Type: mailer.EmailTypeDigest,
UserID: user.ID,
}
if err := p.DB.Create(&notif).Error; err != nil {
}).Error; err != nil {
return errors.Wrap(err, "creating notification")
}
@ -201,12 +206,12 @@ func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) er
return nil
}
func process(p Params, now time.Time, rule database.RepetitionRule) error {
func (c Context) process(now time.Time, rule database.RepetitionRule) error {
log.WithFields(log.Fields{
"uuid": rule.UUID,
}).Info("processing repetition")
tx := p.DB.Begin()
tx := c.DB.Begin()
if !checkCooldown(now, rule) {
log.WithFields(log.Fields{
@ -242,7 +247,7 @@ func process(p Params, now time.Time, rule database.RepetitionRule) error {
return errors.Wrap(err, "committing transaction")
}
if err := notify(p, now, user, digest, rule); err != nil {
if err := c.notify(now, user, digest, rule); err != nil {
return errors.Wrap(err, "notifying user")
}
@ -253,13 +258,20 @@ func process(p Params, now time.Time, rule database.RepetitionRule) error {
return nil
}
// Do creates spaced repetitions and delivers the results based on the rules
func Do(p Params) error {
now := p.Clock.Now().UTC()
// Result holds the result of the job
type Result struct {
SuccessCount int
FailedRuleUUIDs []string
}
rules, err := getEligibleRules(p.DB, now)
// Do creates spaced repetitions and delivers the results based on the rules
func Do(c Context) (Result, error) {
now := c.Clock.Now().UTC()
result := Result{}
rules, err := c.getEligibleRules(now)
if err != nil {
return errors.Wrap(err, "getting eligible repetition rules")
return result, errors.Wrap(err, "getting eligible repetition rules")
}
log.WithFields(log.Fields{
@ -269,13 +281,22 @@ func Do(p Params) error {
}).Info("processing rules")
for _, rule := range rules {
if err := process(p, now, rule); err != nil {
err := c.process(now, rule)
if err == nil {
result.SuccessCount = result.SuccessCount + 1
} else {
log.WithFields(log.Fields{
"rule uuid": rule.UUID,
}).ErrorWrap(err, "Could not process the repetition rule")
continue
result.FailedRuleUUIDs = append(result.FailedRuleUUIDs, rule.UUID)
}
}
return nil
if len(result.FailedRuleUUIDs) > 0 {
return result, errors.New("failed to process some rules")
}
return result, nil
}

View file

@ -19,6 +19,7 @@
package repetition
import (
"os"
"sort"
"testing"
"time"
@ -28,6 +29,7 @@ import (
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) {
@ -38,18 +40,26 @@ func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) {
}
func assertDigestCount(t *testing.T, rule database.RepetitionRule, expected int) {
var digestCount int
testutils.MustExec(t, testutils.DB.Model(&database.Digest{}).Where("rule_id = ? AND user_id = ?", rule.ID, rule.UserID).Count(&digestCount), "counting digest")
assert.Equal(t, digestCount, expected, "digest count mismatch")
}
func getTestParams(c clock.Clock) Params {
return Params{
func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
return Context{
DB: testutils.DB,
Clock: c,
EmailTmpl: mailer.Templates{},
EmailBackend: &testutils.MockEmailbackendImplementation{},
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
EmailBackend: be,
}
}
func mustDo(t *testing.T, c Context) {
_, err := Do(c)
if err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
}
@ -59,6 +69,9 @@ func TestDo(t *testing.T) {
// Set up
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
r1 := database.RepetitionRule{
@ -80,65 +93,84 @@ func TestDo(t *testing.T) {
testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1")
c := clock.NewMock()
be := testutils.MockEmailbackendImplementation{}
con := getTestContext(c, &be)
// Test
// 1 day later
c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(0))
assertDigestCount(t, r1, 0)
assert.Equalf(t, len(be.Emails), 0, "email queue count mismatch")
// 2 days later
c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(0))
assertDigestCount(t, r1, 0)
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
// 3 days later - should be processed
c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(0))
assertDigestCount(t, r1, 0)
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
// 4 day later
c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
// 5 days later
c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257336120000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
// 6 days later - should be processed
c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertDigestCount(t, r1, 2)
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
// 7 days later
c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertDigestCount(t, r1, 2)
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
// 8 days later
c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257595320000))
assertDigestCount(t, r1, 2)
assert.Equal(t, len(be.Emails), 2, "email queue count mismatch")
// 9 days later - should be processed
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
mustDo(t, con)
assertLastActive(t, r1.UUID, int64(1257854520000))
assertDigestCount(t, r1, 3)
assert.Equal(t, len(be.Emails), 3, "email queue count mismatch")
})
/*
@ -160,6 +192,9 @@ func TestDo(t *testing.T) {
// Set up
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
t0 := time.Date(2009, time.November, 1, 12, 2, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
r1 := database.RepetitionRule{
@ -182,7 +217,9 @@ func TestDo(t *testing.T) {
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC))
Do(getTestParams(c))
be := &testutils.MockEmailbackendImplementation{}
mustDo(t, getTestContext(c, be))
var rule database.RepetitionRule
testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&rule), "finding rule1")
@ -190,6 +227,7 @@ func TestDo(t *testing.T) {
assert.Equal(t, rule.LastActive, time.Date(2009, time.November, 10, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "LastActive mismsatch")
assert.Equal(t, rule.NextActive, time.Date(2009, time.November, 13, 12, 2, 0, 0, time.UTC).UnixNano()/int64(time.Millisecond), "NextActive mismsatch")
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
})
}
@ -198,6 +236,9 @@ func TestDo_Disabled(t *testing.T) {
// Set up
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
t0 := time.Date(2009, time.November, 1, 0, 0, 0, 0, time.UTC)
t1 := time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)
r1 := database.RepetitionRule{
@ -221,11 +262,14 @@ func TestDo_Disabled(t *testing.T) {
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC))
Do(getTestParams(c))
be := &testutils.MockEmailbackendImplementation{}
mustDo(t, getTestContext(c, be))
// Test
assertLastActive(t, r1.UUID, int64(0))
assertDigestCount(t, r1, 0)
assert.Equal(t, len(be.Emails), 0, "email queue count mismatch")
}
func TestDo_BalancedStrategy(t *testing.T) {
@ -241,6 +285,8 @@ func TestDo_BalancedStrategy(t *testing.T) {
setup := func() testData {
user := testutils.SetupUserData()
a := testutils.SetupAccountData(user, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "updating email_verified")
b1 := database.Book{
UserID: user.ID,
@ -313,13 +359,15 @@ func TestDo_BalancedStrategy(t *testing.T) {
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
Do(getTestParams(c))
be := &testutils.MockEmailbackendImplementation{}
mustDo(t, getTestContext(c, be))
// Test
assertLastActive(t, r1.UUID, int64(1257714000000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
var repetition database.Digest
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
@ -368,13 +416,15 @@ func TestDo_BalancedStrategy(t *testing.T) {
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC))
Do(getTestParams(c))
be := &testutils.MockEmailbackendImplementation{}
mustDo(t, getTestContext(c, be))
// Test
assertLastActive(t, r1.UUID, int64(1257714000000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
var repetition database.Digest
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")
@ -422,13 +472,15 @@ func TestDo_BalancedStrategy(t *testing.T) {
// Execute
c := clock.NewMock()
c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC))
Do(getTestParams(c))
be := &testutils.MockEmailbackendImplementation{}
mustDo(t, getTestContext(c, be))
// Test
assertLastActive(t, r1.UUID, int64(1257714000000))
assertDigestCount(t, r1, 1)
assert.Equal(t, len(be.Emails), 1, "email queue count mismatch")
var repetition database.Digest
testutils.MustExec(t, testutils.DB.Where("rule_id = ? AND user_id = ?", r1.ID, r1.UserID).Preload("Notes").First(&repetition), "finding repetition")

View file

@ -88,7 +88,6 @@ func (b *SimpleBackendImplementation) Queue(subject, from string, to []string, c
m.SetHeader("To", to...)
m.SetHeader("Subject", subject)
m.SetBody(contentType, body)
// m.SetBody("text/html", body)
p, err := getSMTPParams()
if err != nil {

View file

@ -34,18 +34,24 @@ import (
var (
// EmailTypeResetPassword represents a reset password email
EmailTypeResetPassword = "reset_password"
// EmailTypeWeeklyDigest represents a weekly digest email
EmailTypeWeeklyDigest = "digest"
// EmailTypeResetPasswordAlert represents a password change notification email
EmailTypeResetPasswordAlert = "reset_password_alert"
// EmailTypeDigest represents a weekly digest email
EmailTypeDigest = "digest"
// EmailTypeEmailVerification represents an email verification email
EmailTypeEmailVerification = "verify_email"
// EmailTypeWelcome represents an welcome email
EmailTypeWelcome = "welcome"
// EmailTypeInactiveReminder represents an inactivity reminder email
EmailTypeInactiveReminder = "inactive"
// EmailTypeSubscriptionConfirmation represents an inactivity reminder email
EmailTypeSubscriptionConfirmation = "subscription_confirmation"
)
var (
// EmailKindHTML is the type of html email
EmailKindHTML = "text/html"
// EmailKindHTML is the type of text email
// EmailKindText is the type of text email
EmailKindText = "text/plain"
)
@ -87,7 +93,7 @@ func NewTemplates(srcDir *string) Templates {
box = packr.New("emailTemplates", "./templates/src")
}
weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeWeeklyDigest)
weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeDigest)
if err != nil {
panic(errors.Wrap(err, "initializing weekly digest template"))
}
@ -103,12 +109,27 @@ func NewTemplates(srcDir *string) Templates {
if err != nil {
panic(errors.Wrap(err, "initializing password reset template"))
}
passwordResetAlertText, err := initTextTmpl(box, EmailTypeResetPasswordAlert)
if err != nil {
panic(errors.Wrap(err, "initializing password reset template"))
}
inactiveReminderText, err := initTextTmpl(box, EmailTypeInactiveReminder)
if err != nil {
panic(errors.Wrap(err, "initializing password reset template"))
}
subscriptionConfirmationText, err := initTextTmpl(box, EmailTypeSubscriptionConfirmation)
if err != nil {
panic(errors.Wrap(err, "initializing password reset template"))
}
T := Templates{}
T.set(EmailTypeWeeklyDigest, EmailKindHTML, weeklyDigestHTML)
T.set(EmailTypeDigest, EmailKindHTML, weeklyDigestHTML)
T.set(EmailTypeResetPassword, EmailKindText, passwordResetText)
T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText)
T.set(EmailTypeEmailVerification, EmailKindText, verifyEmailText)
T.set(EmailTypeWelcome, EmailKindText, welcomeText)
T.set(EmailTypeInactiveReminder, EmailKindText, inactiveReminderText)
T.set(EmailTypeSubscriptionConfirmation, EmailKindText, subscriptionConfirmationText)
return T
}

View file

@ -92,6 +92,20 @@ func (c Context) passwordResetHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}
func (c Context) passwordResetAlertHandler(w http.ResponseWriter, r *http.Request) {
data := mailer.EmailResetPasswordAlertTmplData{
AccountEmail: "alice@example.com",
WebURL: "http://localhost:3000",
}
body, err := c.Tmpl.Execute(mailer.EmailTypeResetPasswordAlert, mailer.EmailKindText, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte(body))
}
func (c Context) emailVerificationHandler(w http.ResponseWriter, r *http.Request) {
data := mailer.EmailVerificationTmplData{
Token: "testToken",
@ -120,6 +134,21 @@ func (c Context) welcomeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(body))
}
func (c Context) inactiveHandler(w http.ResponseWriter, r *http.Request) {
data := mailer.InactiveReminderTmplData{
SampleNoteUUID: "some-uuid",
WebURL: "http://localhost:3000",
Token: "some-random-token",
}
body, err := c.Tmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Write([]byte(body))
}
func (c Context) homeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Email development server is running."))
}
@ -156,6 +185,8 @@ func main() {
http.HandleFunc("/digest", ctx.digestHandler)
http.HandleFunc("/email-verification", ctx.emailVerificationHandler)
http.HandleFunc("/password-reset", ctx.passwordResetHandler)
http.HandleFunc("/password-reset-alert", ctx.passwordResetAlertHandler)
http.HandleFunc("/welcome", ctx.welcomeHandler)
http.HandleFunc("/inactive-reminder", ctx.inactiveHandler)
log.Fatal(http.ListenAndServe(":2300", nil))
}

View file

@ -0,0 +1,9 @@
Hi, nothing has been added to your Dnote for some time.
What about revisiting one of your previous knowledge? {{ .WebURL }}/notes/{{ .SampleNoteUUID }}
Expand your knowledge base at {{ .WebURL }}/new or using Dnote apps.
- Dnote team
UNSUBSCRIBE: {{ .WebURL }}/settings/notifications?token={{ .Token }}

View file

@ -0,0 +1,9 @@
Hi,
This email is to notify you that the password for your Dnote account "{{ .AccountEmail }}" has changed.
If you did not initiate this password change, please notify us by replying, and reset your password at {{ .WebURL }}/password-reset
Thanks.
- Sung (Maker of Dnote)

View file

@ -0,0 +1,10 @@
Hi, thanks for signing up for Dnote Pro.
Dnote Pro is your own simple personal knowledge base that helps you retain new information.
Your account is "{{ .AccountEmail }}". Log in at {{ .WebURL }}/login
I appreciate your support. It makes it possible to develop Dnote and help learners around the world.
Thank you for using my software.
- Sung (Maker of Dnote)

View file

@ -72,8 +72,27 @@ type EmailResetPasswordTmplData struct {
WebURL string
}
// EmailResetPasswordAlertTmplData is a template data for reset password emails
type EmailResetPasswordAlertTmplData struct {
AccountEmail string
WebURL string
}
// WelcomeTmplData is a template data for welcome emails
type WelcomeTmplData struct {
AccountEmail string
WebURL string
}
// InactiveReminderTmplData is a template data for welcome emails
type InactiveReminderTmplData struct {
SampleNoteUUID string
WebURL string
Token string
}
// EmailTypeSubscriptionConfirmationTmplData is a template data for reset password emails
type EmailTypeSubscriptionConfirmationTmplData struct {
AccountEmail string
WebURL string
}

View file

@ -108,19 +108,21 @@ func initApp() app.App {
db := initDB()
return app.App{
DB: db,
Clock: clock.New(),
StripeAPIBackend: nil,
EmailTemplates: mailer.NewTemplates(nil),
EmailBackend: &mailer.SimpleBackendImplementation{},
WebURL: os.Getenv("WebURL"),
OnPremise: true,
DisableRegistration: os.Getenv("DisableRegistration") == "true",
DB: db,
Clock: clock.New(),
StripeAPIBackend: nil,
EmailTemplates: mailer.NewTemplates(nil),
EmailBackend: &mailer.SimpleBackendImplementation{},
Config: app.Config{
WebURL: os.Getenv("WebURL"),
OnPremise: true,
DisableRegistration: os.Getenv("DisableRegistration") == "true",
},
}
}
func runJob(a app.App) error {
jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.WebURL)
jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.Config)
if err != nil {
return errors.Wrap(err, "getting a job runner")
}

View file

@ -26,17 +26,19 @@ import (
// EmailPreference is a presented email digest
type EmailPreference struct {
DigestWeekly bool `json:"digest_weekly"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
InactiveReminder bool `json:"inactive_reminder"`
ProductUpdate bool `json:"product_update"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PresentEmailPreference presents a digest
func PresentEmailPreference(p database.EmailPreference) EmailPreference {
ret := EmailPreference{
DigestWeekly: p.DigestWeekly,
CreatedAt: FormatTS(p.CreatedAt),
UpdatedAt: FormatTS(p.UpdatedAt),
InactiveReminder: p.InactiveReminder,
ProductUpdate: p.ProductUpdate,
CreatedAt: FormatTS(p.CreatedAt),
UpdatedAt: FormatTS(p.UpdatedAt),
}
return ret

View file

@ -173,10 +173,10 @@ func SetupSession(t *testing.T, user database.User) database.Session {
}
// SetupEmailPreferenceData creates and returns a new email frequency for a user
func SetupEmailPreferenceData(user database.User, digestWeekly bool) database.EmailPreference {
func SetupEmailPreferenceData(user database.User, inactiveReminder bool) database.EmailPreference {
frequency := database.EmailPreference{
UserID: user.ID,
DigestWeekly: digestWeekly,
UserID: user.ID,
InactiveReminder: inactiveReminder,
}
if err := DB.Save(&frequency).Error; err != nil {
@ -297,6 +297,14 @@ type MockEmailbackendImplementation struct {
Emails []MockEmail
}
// Clear clears the mock email queue
func (b *MockEmailbackendImplementation) Clear() {
b.mu.Lock()
defer b.mu.Unlock()
b.Emails = []MockEmail{}
}
// Queue is an implementation of Backend.Queue.
func (b *MockEmailbackendImplementation) Queue(subject, from string, to []string, contentType, body string) error {
b.mu.Lock()

View file

@ -16,3 +16,20 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package token
import (
"os"
"testing"
"github.com/dnote/dnote/pkg/server/testutils"
)
func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData()
os.Exit(code)
}

59
pkg/server/token/token.go Normal file
View file

@ -0,0 +1,59 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package token
import (
"crypto/rand"
"encoding/base64"
"github.com/dnote/dnote/pkg/server/database"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
// generateRandom generates random bits of given length
func generateRandom(bits int) (string, error) {
b := make([]byte, bits)
_, err := rand.Read(b)
if err != nil {
return "", errors.Wrap(err, "reading random bytes")
}
return base64.URLEncoding.EncodeToString(b), nil
}
// Create generates a new token in the database
func Create(db *gorm.DB, userID int, kind string) (database.Token, error) {
val, err := generateRandom(16)
if err != nil {
return database.Token{}, errors.Wrap(err, "generating random bytes")
}
token := database.Token{
UserID: userID,
Value: val,
Type: kind,
}
if err := db.Save(&token).Error; err != nil {
return database.Token{}, errors.Wrap(err, "creating a token for unsubscribing")
}
return token, nil
}

View file

@ -0,0 +1,65 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package token
import (
"fmt"
"testing"
"github.com/dnote/dnote/pkg/assert"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/testutils"
"github.com/pkg/errors"
)
func TestCreate(t *testing.T) {
testCases := []struct {
kind string
}{
{
kind: database.TokenTypeEmailPreference,
},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("token type %s", tc.kind), func(t *testing.T) {
defer testutils.ClearData()
// Set up
u := testutils.SetupUserData()
// Execute
tok, err := Create(testutils.DB, u.ID, tc.kind)
if err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
// Test
var count int
testutils.MustExec(t, testutils.DB.Model(&database.Token{}).Count(&count), "counting token")
assert.Equalf(t, count, 1, "error mismatch")
var tokenRecord database.Token
testutils.MustExec(t, testutils.DB.First(&tokenRecord), "finding token")
assert.Equalf(t, tokenRecord.UserID, tok.UserID, "UserID mismatch")
assert.Equalf(t, tokenRecord.Value, tok.Value, "Value mismatch")
assert.Equalf(t, tokenRecord.Type, tok.Type, "Type mismatch")
})
}
}

View file

@ -1,254 +0,0 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/dbconn"
"github.com/dnote/dnote/pkg/server/helpers"
"os"
"time"
)
func main() {
db, err := dbconn.Open(dbconn.Config{
Host: os.Getenv("DBHost"),
Port: os.Getenv("DBPort"),
Name: os.Getenv("DBName"),
User: os.Getenv("DBUser"),
Password: os.Getenv("DBPassword"),
})
if err != nil {
panic(err)
}
tx := db.Begin()
userID, err := helpers.GetDemoUserID(db)
if err != nil {
panic(err)
}
var d1Notes []database.Note
var d2Notes []database.Note
var d3Notes []database.Note
var d4Notes []database.Note
var d5Notes []database.Note
var d6Notes []database.Note
var d7Notes []database.Note
var d8Notes []database.Note
var d9Notes []database.Note
var d10Notes []database.Note
var d11Notes []database.Note
var d12Notes []database.Note
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d1Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d2Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d3Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d4Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d5Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d6Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d7Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d8Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d9Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d10Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d11Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
if err := db.Order("random()").Limit(5).Where("user_id = ?", userID).Find(&d12Notes).Error; err != nil {
tx.Rollback()
panic(err)
}
d1Date := time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC)
d1 := database.Digest{
UserID: userID,
Notes: d1Notes,
CreatedAt: d1Date,
UpdatedAt: d1Date,
}
if err := tx.Save(&d1).Error; err != nil {
tx.Rollback()
panic(err)
}
d2Date := time.Date(2019, time.February, 4, 0, 0, 0, 0, time.UTC)
d2 := database.Digest{
UserID: userID,
Notes: d2Notes,
CreatedAt: d2Date,
UpdatedAt: d2Date,
}
if err := tx.Save(&d2).Error; err != nil {
tx.Rollback()
panic(err)
}
d3Date := time.Date(2019, time.February, 12, 0, 0, 0, 0, time.UTC)
d3 := database.Digest{
UserID: userID,
Notes: d3Notes,
CreatedAt: d3Date,
UpdatedAt: d3Date,
}
if err := tx.Save(&d3).Error; err != nil {
tx.Rollback()
panic(err)
}
d4Date := time.Date(2019, time.May, 12, 0, 0, 0, 0, time.UTC)
d4 := database.Digest{
UserID: userID,
Notes: d4Notes,
CreatedAt: d4Date,
UpdatedAt: d4Date,
}
if err := tx.Save(&d4).Error; err != nil {
tx.Rollback()
panic(err)
}
d5Date := time.Date(2019, time.March, 10, 0, 0, 0, 0, time.UTC)
d5 := database.Digest{
UserID: userID,
Notes: d5Notes,
CreatedAt: d5Date,
UpdatedAt: d5Date,
}
if err := tx.Save(&d5).Error; err != nil {
tx.Rollback()
panic(err)
}
d6Date := time.Date(2019, time.February, 20, 0, 0, 0, 0, time.UTC)
d6 := database.Digest{
UserID: userID,
Notes: d6Notes,
CreatedAt: d6Date,
UpdatedAt: d6Date,
}
if err := tx.Save(&d6).Error; err != nil {
tx.Rollback()
panic(err)
}
d7Date := time.Date(2019, time.April, 24, 0, 0, 0, 0, time.UTC)
d7 := database.Digest{
UserID: userID,
Notes: d7Notes,
CreatedAt: d7Date,
UpdatedAt: d7Date,
}
if err := tx.Save(&d7).Error; err != nil {
tx.Rollback()
panic(err)
}
d8Date := time.Date(2018, time.December, 6, 0, 0, 0, 0, time.UTC)
d8 := database.Digest{
UserID: userID,
Notes: d8Notes,
CreatedAt: d8Date,
UpdatedAt: d8Date,
}
if err := tx.Save(&d8).Error; err != nil {
tx.Rollback()
panic(err)
}
d9Date := time.Date(2018, time.November, 2, 0, 0, 0, 0, time.UTC)
d9 := database.Digest{
UserID: userID,
Notes: d9Notes,
CreatedAt: d9Date,
UpdatedAt: d9Date,
}
if err := tx.Save(&d9).Error; err != nil {
tx.Rollback()
panic(err)
}
d10Date := time.Date(2018, time.October, 12, 0, 0, 0, 0, time.UTC)
d10 := database.Digest{
UserID: userID,
Notes: d10Notes,
CreatedAt: d10Date,
UpdatedAt: d10Date,
}
if err := tx.Save(&d10).Error; err != nil {
tx.Rollback()
panic(err)
}
d11Date := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC)
d11 := database.Digest{
UserID: userID,
Notes: d11Notes,
CreatedAt: d11Date,
UpdatedAt: d11Date,
}
if err := tx.Save(&d11).Error; err != nil {
tx.Rollback()
panic(err)
}
d12Date := time.Date(2018, time.May, 17, 0, 0, 0, 0, time.UTC)
d12 := database.Digest{
UserID: userID,
Notes: d12Notes,
CreatedAt: d12Date,
UpdatedAt: d12Date,
}
if err := tx.Save(&d12).Error; err != nil {
tx.Rollback()
panic(err)
}
tx.Commit()
}

View file

@ -9,7 +9,7 @@ pushd "$dir/../../pkg/server"
export DNOTE_TEST_EMAIL_TEMPLATE_DIR="$dir/../../pkg/server/mailer/templates/src"
function run_test {
go test ./... -cover -p 1
go test ./... -cover -p 1
}
if [ "${WATCH-false}" == true ]; then

89
web/package-lock.json generated
View file

@ -6227,7 +6227,8 @@
},
"ansi-regex": {
"version": "2.1.1",
"bundled": true
"bundled": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -6245,11 +6246,13 @@
},
"balanced-match": {
"version": "1.0.0",
"bundled": true
"bundled": true,
"optional": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -6262,15 +6265,18 @@
},
"code-point-at": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true
"bundled": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -6373,7 +6379,8 @@
},
"inherits": {
"version": "2.0.4",
"bundled": true
"bundled": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -6383,6 +6390,7 @@
"is-fullwidth-code-point": {
"version": "1.0.0",
"bundled": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -6395,17 +6403,20 @@
"minimatch": {
"version": "3.0.4",
"bundled": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"minimist": {
"version": "0.0.8",
"bundled": true
"bundled": true,
"optional": true
},
"minipass": {
"version": "2.9.0",
"bundled": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -6422,6 +6433,7 @@
"mkdirp": {
"version": "0.5.1",
"bundled": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -6502,7 +6514,8 @@
},
"number-is-nan": {
"version": "1.0.1",
"bundled": true
"bundled": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -6512,6 +6525,7 @@
"once": {
"version": "1.4.0",
"bundled": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -6587,7 +6601,8 @@
},
"safe-buffer": {
"version": "5.1.2",
"bundled": true
"bundled": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -6617,6 +6632,7 @@
"string-width": {
"version": "1.0.2",
"bundled": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -6634,6 +6650,7 @@
"strip-ansi": {
"version": "3.0.1",
"bundled": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -6672,11 +6689,13 @@
},
"wrappy": {
"version": "1.0.2",
"bundled": true
"bundled": true,
"optional": true
},
"yallist": {
"version": "3.1.1",
"bundled": true
"bundled": true,
"optional": true
}
}
},
@ -8328,7 +8347,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -8743,7 +8763,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -8799,6 +8820,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -8842,12 +8864,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},
@ -15150,7 +15174,8 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"aproba": {
"version": "1.2.0",
@ -15178,6 +15203,7 @@
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -15192,7 +15218,8 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"concat-map": {
"version": "0.0.1",
@ -15203,7 +15230,8 @@
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"core-util-is": {
"version": "1.0.2",
@ -15320,7 +15348,8 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"ini": {
"version": "1.3.5",
@ -15332,6 +15361,7 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -15346,6 +15376,7 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -15353,12 +15384,14 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -15377,6 +15410,7 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -15457,7 +15491,8 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"object-assign": {
"version": "4.1.1",
@ -15469,6 +15504,7 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -15554,7 +15590,8 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
@ -15590,6 +15627,7 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -15609,6 +15647,7 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -15652,12 +15691,14 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true
"dev": true,
"optional": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true
"dev": true,
"optional": true
}
}
},

View file

@ -64,8 +64,10 @@ function useFetchData() {
});
useEffect(() => {
dispatch(getCurrentUser());
}, [dispatch]);
if (!user.isFetched) {
dispatch(getCurrentUser());
}
}, [dispatch, user.isFetched]);
useEffect(() => {
if (user.isFetched) {

View file

@ -29,7 +29,7 @@ const digestWeekly = 'weekly';
const digestNever = 'never';
function getDigestFrequency(emailPreference: any): string {
if (emailPreference.digest_weekly) {
if (emailPreference.inactive_reminder) {
return digestWeekly;
}
@ -64,7 +64,7 @@ const EmailPreferenceForm: React.FunctionComponent<Props> = ({
setInProgress(true);
services.users
.updateEmailPreference({ digestFrequency, token })
.updateEmailPreference({ inactiveReminder: true, token })
.then(updatedPreference => {
dispatch(receiveEmailPreference(updatedPreference));

View file

@ -0,0 +1,60 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../App/responsive';
@import '../App/theme';
@import '../App/rem';
@import '../App/font';
.wrapper {
text-align: center;
height: 100vh;
padding: rem(52px) 0;
background: $light-gray;
}
.heading {
@include font-size('2x-large');
color: $black;
font-weight: 300;
margin-top: rem(16px);
}
.body {
text-align: left;
padding: rem(20px) rem(28px);
margin-top: rem(20px);
max-width: rem(700px);
margin-left: auto;
margin-right: auto;
background-color: #fff;
}
.footer {
@include font-size('small');
text-align: center;
margin-top: rem(20px);
a {
color: $gray;
}
}
.flash {
margin-bottom: rem(8px);
}

View file

@ -0,0 +1,112 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState, useEffect } from 'react';
import classnames from 'classnames';
import { RouteComponentProps } from 'react-router';
import { Link, withRouter } from 'react-router-dom';
import Helmet from 'react-helmet';
import { parseSearchString } from 'jslib/helpers/url';
import { getHomePath, getLoginPath } from 'web/libs/paths';
import Form from '../Settings/Notifications/Form';
import { getEmailPreference } from '../../store/auth';
import Logo from '../Icons/Logo';
import { useSelector, useDispatch } from '../../store';
import Flash from '../Common/Flash';
import styles from './EmailPreference.scss';
interface Props extends RouteComponentProps {}
const EmailPreference: React.FunctionComponent<Props> = ({ location }) => {
const { token } = parseSearchString(location.search);
const [successMsg, setSuccessMsg] = useState('');
const [failureMsg, setFailureMsg] = useState('');
const dispatch = useDispatch();
const { emailPreference } = useSelector(state => {
return {
emailPreference: state.auth.emailPreference
};
});
useEffect(() => {
if (!emailPreference.isFetched) {
dispatch(getEmailPreference(token));
}
}, [dispatch, emailPreference.isFetched, token]);
return (
<div className={styles.wrapper}>
<Helmet>
<title>Email Preferences</title>
</Helmet>
<Link to="/">
<Logo fill="#252833" width={60} height={60} />
</Link>
<h1 className={styles.heading}>Dnote email preferences</h1>
<div className="container">
<div className={styles.body}>
<Flash
kind="danger"
wrapperClassName={styles.flash}
when={emailPreference.errorMessage !== ''}
>
Error fetching email preference: {emailPreference.errorMessage}.
Please try again after <Link to={getLoginPath()}>logging in</Link>.
</Flash>
<Flash
id="T-notifications-success"
kind="success"
wrapperClassName={classnames(styles.flash, 'T-success')}
when={successMsg !== ''}
>
{successMsg}
</Flash>
<Flash
kind="danger"
wrapperClassName={styles.flash}
when={failureMsg !== ''}
>
{failureMsg}
</Flash>
{emailPreference.isFetched && (
<Form
token={token}
emailPreference={emailPreference.data}
setSuccessMsg={setSuccessMsg}
setFailureMsg={setFailureMsg}
/>
)}
</div>
<div className={styles.footer}>
<Link to={getHomePath()}>Back to Dnote home</Link>
</div>
</div>
</div>
);
};
export default withRouter(EmailPreference);

View file

@ -0,0 +1,45 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../App/rem';
@import '../../App/font';
@import '../../App/theme';
.heading {
font-weight: 600;
@include font-size('regular');
}
.subtext {
@include font-size('small');
color: $gray;
margin-top: rem(4px);
}
.actions {
text-align: right;
}
.section ~ .section {
margin-top: rem(16px);
}
.label {
margin-left: rem(8px);
vertical-align: middle;
}

View file

@ -0,0 +1,155 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState, useReducer } from 'react';
import services from 'web/libs/services';
import { EmailPrefData } from 'jslib/operations/types';
import Button from '../../Common/Button';
import { receiveEmailPreference } from '../../../store/auth';
import { useDispatch } from '../../../store';
import styles from './Form.scss';
enum Action {
setInactiveReminder,
setProductUpdate
}
function formReducer(state, action): EmailPrefData {
switch (action.type) {
case Action.setInactiveReminder:
return {
...state,
inactiveReminder: action.data
};
case Action.setProductUpdate:
return {
...state,
productUpdate: action.data
};
default:
return state;
}
}
interface Props {
emailPreference: EmailPrefData;
setSuccessMsg: (string) => void;
setFailureMsg: (string) => void;
token?: string;
}
const Form: React.FunctionComponent<Props> = ({
emailPreference,
setSuccessMsg,
setFailureMsg,
token
}) => {
const [inProgress, setInProgress] = useState(false);
const dispatch = useDispatch();
const [formState, formDispatch] = useReducer(formReducer, emailPreference);
function handleSubmit(e) {
e.preventDefault();
setSuccessMsg('');
setFailureMsg('');
setInProgress(true);
services.users
.updateEmailPreference({
inactiveReminder: formState.inactiveReminder,
productUpdate: formState.productUpdate,
token
})
.then(updatedPreference => {
dispatch(receiveEmailPreference(updatedPreference));
setSuccessMsg('Updated email preference');
setInProgress(false);
})
.catch(err => {
setFailureMsg(`Failed to update. Error: ${err.message}`);
setInProgress(false);
});
}
return (
<form id="T-notifications-form" onSubmit={handleSubmit}>
<div className={styles.section}>
<h3 className={styles.heading}>Alerts</h3>
<p className={styles.subtext}>Email me when:</p>
<ul className="list-unstyled">
<li>
<input
type="checkbox"
id="inactive-reminder"
checked={formState.inactiveReminder}
onChange={e => {
const { checked } = e.target;
formDispatch({
type: Action.setInactiveReminder,
data: checked
});
}}
/>
<label className={styles.label} htmlFor="inactive-reminder">
I stop learning new things
</label>
</li>
</ul>
</div>
<div className={styles.section}>
<h3 className={styles.heading}>News</h3>
<p className={styles.subtext}>Email me about:</p>
<ul className="list-unstyled">
<li>
<input
type="checkbox"
id="product-update"
checked={formState.productUpdate}
onChange={e => {
const { checked } = e.target;
formDispatch({
type: Action.setProductUpdate,
data: checked
});
}}
/>
<label className={styles.label} htmlFor="product-update">
New features and updates
</label>
</li>
</ul>
</div>
<div className={styles.actions}>
<Button type="submit" kind="first" size="normal" isBusy={inProgress}>
Update
</Button>
</div>
</form>
);
};
export default Form;

View file

@ -16,3 +16,10 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
@import '../../App/rem';
@import '../../App/font';
@import '../../App/theme';
.body {
padding: rem(16px) rem(20px);
}

View file

@ -0,0 +1,110 @@
/* Copyright (C) 2019 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import React, { useState, useEffect } from 'react';
import Helmet from 'react-helmet';
import { withRouter, RouteComponentProps } from 'react-router';
import { parseSearchString } from 'jslib/helpers/url';
import Flash from '../../Common/Flash';
import { useSelector } from '../../../store';
import Form from './Form';
import settingsStyles from '../Settings.scss';
import { useDispatch } from '../../../store';
import { getEmailPreference } from '../../../store/auth';
import styles from './Notifications.scss';
interface Props extends RouteComponentProps {}
const Notifications: React.FunctionComponent<Props> = ({ location }) => {
const [successMsg, setSuccessMsg] = useState('');
const [failureMsg, setFailureMsg] = useState('');
const dispatch = useDispatch();
const { token } = parseSearchString(location.search);
const { emailPreference } = useSelector(state => {
return {
emailPreference: state.auth.emailPreference
};
});
useEffect(() => {
if (!emailPreference.isFetched) {
dispatch(getEmailPreference());
}
}, [dispatch, emailPreference.isFetched]);
return (
<div>
<Helmet>
<title>Notifications</title>
</Helmet>
<h1 className="sr-only">Notifications</h1>
<Flash
id="T-notifications-success"
when={successMsg !== ''}
kind="success"
onDismiss={() => {
setSuccessMsg('');
}}
>
{successMsg}
</Flash>
<Flash
when={failureMsg !== ''}
kind="danger"
onDismiss={() => {
setFailureMsg('');
}}
>
{failureMsg}
</Flash>
<Flash when={emailPreference.errorMessage !== ''} kind="danger">
{emailPreference.errorMessage}
</Flash>
<div className={settingsStyles.wrapper}>
<section className={settingsStyles.section}>
<h2 className={settingsStyles['section-heading']}>
Email Preferences
</h2>
<div className={styles.body}>
{emailPreference.isFetched ? (
<Form
emailPreference={emailPreference.data}
setSuccessMsg={setSuccessMsg}
setFailureMsg={setFailureMsg}
token={token}
/>
) : (
<span>Loading email preferences...</span>
)}
</div>
</section>
</div>
</div>
);
};
export default withRouter(Notifications);

View file

@ -47,6 +47,15 @@ const Sidebar: React.FunctionComponent<Props> = () => {
Billing
</NavLink>
</li>
<li>
<NavLink
className={styles.item}
activeClassName={styles.active}
to={getSettingsPath(SettingSections.notifications)}
>
Notifications
</NavLink>
</li>
<li>
<NavLink
className={styles.item}

View file

@ -25,6 +25,7 @@ import { SettingSections } from 'web/libs/paths';
import Account from './Account';
import Sidebar from './Sidebar';
import About from './About';
import Notifications from './Notifications';
import Billing from './Billing';
import styles from './Settings.scss';
@ -38,6 +39,9 @@ function renderContent(section: string): React.ReactNode {
if (section === SettingSections.about) {
return <About />;
}
if (section === SettingSections.notifications) {
return <Notifications />;
}
return <div>Not found</div>;
}

View file

@ -41,6 +41,7 @@ export const repetitionsPathDef = '/repetition';
export const repetitionPathDef = '/repetition/:repetitionUUID';
export const newRepetitionRulePathDef = '/repetition/new';
export const editRepetitionRulePathDef = '/repetition/:repetitionUUID/edit';
export const emailPreferencePathDef = '/email-preferences';
// layout definitions
export const noHeaderPaths = [
@ -50,7 +51,8 @@ export const noHeaderPaths = [
verifyEmailPathDef,
classicMigrationPathDef,
passwordResetRequestPathDef,
passwordResetConfirmPathDef
passwordResetConfirmPathDef,
emailPreferencePathDef
];
export const noFooterPaths = [
loginPathDef,
@ -61,7 +63,8 @@ export const noFooterPaths = [
verifyEmailPathDef,
classicMigrationPathDef,
passwordResetRequestPathDef,
passwordResetConfirmPathDef
passwordResetConfirmPathDef,
emailPreferencePathDef
];
export const subscriptionPaths = [
subscriptionsPathDef,
@ -211,6 +214,7 @@ export function getPasswordResetConfirmPath(searchObj = {}): Location {
export enum SettingSections {
account = 'account',
billing = 'billing',
notifications = 'notifications',
about = 'about'
}

View file

@ -42,6 +42,9 @@ import NewRepetition from './components/Repetition/New';
import EditRepetition from './components/Repetition/Edit';
import PasswordResetRequest from './components/PasswordReset/Request';
import PasswordResetConfirm from './components/PasswordReset/Confirm';
import EmailPreference from './components/EmailPreference';
// paths
import {
notePathDef,
homePathDef,
@ -61,7 +64,8 @@ import {
classicMigrationPathDef,
repetitionsPathDef,
newRepetitionRulePathDef,
editRepetitionRulePathDef
editRepetitionRulePathDef,
emailPreferencePathDef
} from './libs/paths';
const AuthenticatedHome = userOnly(Home);
@ -176,6 +180,11 @@ const routes = [
exact: true,
component: AuthenticatedEditRepetition
},
{
path: emailPreferencePathDef,
exact: true,
component: EmailPreference
},
{
component: NotFound
}

View file

@ -17,7 +17,8 @@
*/
import services from 'web/libs/services';
import { UserData, EmailPrefData, SourceData, SubscriptionData } from './type';
import { EmailPrefData } from 'jslib/operations/types';
import { UserData, SourceData, SubscriptionData } from './type';
import { ThunkAction } from '../types';
import {

View file

@ -16,12 +16,12 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import { EmailPrefData } from 'jslib/operations/types';
import { RemoteData } from '../types';
import {
AuthState,
AuthActionType,
UserData,
EmailPrefData,
SubscriptionData,
SourceData,
RECEIVE_EMAIL_PREFERENCE,
@ -56,7 +56,8 @@ export const initialState: AuthState = {
isFetching: false,
isFetched: false,
data: {
digestWeekly: false
inactiveReminder: false,
productUpdate: false
},
errorMessage: ''
},

View file

@ -16,6 +16,7 @@
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
import { EmailPrefData } from 'jslib/operations/types';
import { RemoteData } from '../types';
export interface UserData {
@ -27,10 +28,6 @@ export interface UserData {
classic: boolean;
}
export interface EmailPrefData {
digestWeekly: boolean;
}
// TODO: type
export type SubscriptionData = any;
export type SourceData = any;