chore: import mailer (#869)

* chore: import mailer

* chore: update template

* chore: config template

* fix: add missing config parameter

* chore: update template

* chore: show error with task id

* chore: show error with task id

---------

Co-authored-by: khorshuheng <solemnpriest@gmail.com>
This commit is contained in:
Nathan.fooo 2024-10-13 20:08:55 +08:00 committed by GitHub
parent ec2cc309bd
commit bbd21dfef6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 809 additions and 184 deletions

View file

@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT name, email FROM af_user WHERE uuid = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "email",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false
]
},
"hash": "24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716"
}

17
Cargo.lock generated
View file

@ -535,9 +535,9 @@ checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1"
[[package]]
name = "anyhow"
version = "1.0.86"
version = "1.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6"
[[package]]
name = "app-error"
@ -641,6 +641,7 @@ dependencies = [
"lazy_static",
"lettre",
"log",
"mailer",
"mime",
"once_cell",
"opener",
@ -807,6 +808,7 @@ dependencies = [
"dotenvy",
"futures",
"infra",
"mailer",
"mime_guess",
"redis 0.25.4",
"secrecy",
@ -4473,6 +4475,17 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mailer"
version = "0.1.0"
dependencies = [
"anyhow",
"handlebars",
"lettre",
"secrecy",
"serde",
]
[[package]]
name = "markdown"
version = "1.0.0-alpha.21"

View file

@ -148,6 +148,7 @@ pin-project = "1.1.5"
byteorder = "1.5.0"
sha2 = "0.10.8"
rayon.workspace = true
mailer.workspace = true
[dev-dependencies]
@ -213,6 +214,7 @@ members = [
# xtask
"xtask",
"libs/tonic-proto",
"libs/mailer",
]
[workspace.dependencies]
@ -224,6 +226,7 @@ shared-entity = { path = "libs/shared-entity" }
gotrue-entity = { path = "libs/gotrue-entity" }
authentication = { path = "libs/authentication" }
access-control = { path = "libs/access-control" }
mailer = { path = "libs/mailer" }
app-error = { path = "libs/app-error" }
async-trait = "0.1.77"
prometheus-client = "0.22.0"

View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
<title>Workspace Import Failed</title>
<style>
@media (max-width: 600px) {
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-py-12 {
padding-top: 48px !important;
padding-bottom: 48px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
<div style="display: none">
There was an issue with your workspace import
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
<div role="article" aria-roledescription="email" aria-label="Workspace Import Failed" lang="en">
<div class="sm-px-4 sm-py-12" style="background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<p style="width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 18px; color: #dc2626">
<span>Failed to import your notion file {{ import_file_name }}</span>
<br>
<span style="margin-left: 8px; margin-right: 8px">{{ error }}</span>
</p>
</td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569">
<p style="margin: 0 0 16px; cursor: pointer; text-transform: uppercase">
<a href="https://appflowy.io">
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png" width="150px" style="max-width: 100%; vertical-align: middle; line-height: 1" alt="">
</a>
</p>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>
<script>
document
.getElementById("copyErrorDetails")
.addEventListener("click", function() {
const errorDetails = document.getElementById("errorDetails");
errorDetails.select();
document.execCommand("copy");
alert("Error details copied to clipboard!");
});
</script>

View file

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
<title>Workspace Import Success</title>
<style>
@media (max-width: 600px) {
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-py-12 {
padding-top: 48px !important;
padding-bottom: 48px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; background-color: #faf5ff; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
<div style="display: none">
Your workspace import was successful
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
<div role="article" aria-roledescription="email" aria-label="Workspace Import Success" lang="en">
<div class="sm-px-4 sm-py-12" style="background-color: #faf5ff; padding: 96px 48px; font-family: Helvetica, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; color: #000">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<p style="width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 18px">
<span style="margin-left: 8px; margin-right: 8px">
Your Notion files has been successfully imported. Please open AppFlowy and switch to the new workspace
</span>
<br>
<span style="font-size: 24px; font-weight: 700">
{{ workspace_name }}
</span>
</p>
<div role="separator" style="background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%">&zwj;</div>
</td>
</tr>
<tr>
<td style="padding-left: 24px; padding-right: 24px; text-align: center; font-size: 12px; color: #475569">
<p style="margin: 0 0 16px; cursor: pointer; text-transform: uppercase">
<a href="https://appflowy.io">
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/appflowy-logo.png" width="150px" style="max-width: 100%; vertical-align: middle; line-height: 1" alt="">
</a>
</p>
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -128,7 +128,7 @@ APPFLOWY_COLLABORATE_MULTI_THREAD=false
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
# AppFlowy Worker
APPFLOWY_WORKER_REDIS_URL=redis://redis:6379
APPFLOWY_WORKER_REDIS_URL=redis://localhost:6379
APPFLOWY_WORKER_DATABASE_URL=postgres://postgres:password@postgres:5432/postgres
# AppFlowy Web

View file

@ -176,6 +176,10 @@ services:
- APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}
- APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}
- APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}
- APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}
- APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}
- APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}
- APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}
volumes:
postgres_data:

View file

@ -170,6 +170,10 @@ services:
- APPFLOWY_S3_SECRET_KEY=${APPFLOWY_S3_SECRET_KEY}
- APPFLOWY_S3_BUCKET=${APPFLOWY_S3_BUCKET}
- APPFLOWY_S3_REGION=${APPFLOWY_S3_REGION}
- APPFLOWY_MAILER_SMTP_HOST=${APPFLOWY_MAILER_SMTP_HOST}
- APPFLOWY_MAILER_SMTP_PORT=${APPFLOWY_MAILER_SMTP_PORT}
- APPFLOWY_MAILER_SMTP_USERNAME=${APPFLOWY_MAILER_SMTP_USERNAME}
- APPFLOWY_MAILER_SMTP_PASSWORD=${APPFLOWY_MAILER_SMTP_PASSWORD}
volumes:
postgres_data:
minio_data:

View file

@ -27,6 +27,8 @@ module.exports = {
locals: {
cdnBaseUrl: "",
userIconUrl: "https://cdn-icons-png.flaticon.com/512/1077/1077012.png",
error: "Test error message",
detailError: "Test detail error message",
userName: "John Doe",
acceptUrl: "https://appflowy.io",
approveUrl: "https://appflowy.io",

View file

@ -21,8 +21,12 @@ module.exports = {
},
locals: {
cdnBaseUrl:
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/",
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/",
error: "{{ error }}",
detailError: "{{ error_detail }}",
userIconUrl: "{{ user_icon_url }}",
importFileName: "{{ import_file_name }}",
importTaskId: "{{ import_task_id }}",
userName: "{{ username }}",
acceptUrl: "{{ accept_url }}",
approveUrl: "{{ approve_url }}",

View file

@ -0,0 +1,50 @@
---
title: "Workspace Import Failed"
preheader: "There was an issue with your workspace import"
bodyClass: bg-purple-50
---
<x-main>
<div
class="bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black"
>
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<!-- Display Error Message -->
<p
class="w-full text-center break-words whitespace-normal text-lg text-red-600"
>
<span class=class="mx-2">Failed to import your notion file {{ importFileName }}</span>
<br>
<span class="mx-2">{{ error }}</span>
</p>
</td>
</tr>
<tr>
<td class="text-center text-slate-600 text-xs px-6">
<p class="m-0 mb-4 uppercase cursor-pointer">
<a href="https://appflowy.io">
<img
src="{{ cdnBaseUrl }}images/appflowy-logo.png"
width="150px"
/>
</a>
</p>
</td>
</tr>
</table>
</div>
</x-main>
<!-- JavaScript to handle copying error details to clipboard -->
<script>
document
.getElementById("copyErrorDetails")
.addEventListener("click", function () {
const errorDetails = document.getElementById("errorDetails");
errorDetails.select();
document.execCommand("copy");
alert("Error details copied to clipboard!");
});
</script>

View file

@ -0,0 +1,39 @@
---
title: "Workspace Import Success"
preheader: "Your workspace import was successful"
bodyClass: bg-purple-50
---
<x-main>
<div
class="bg-purple-50 font-helvetica sm:px-4 px-12 sm:py-12 py-24 text-black"
>
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<!-- Display Success Message -->
<p class="w-full text-center break-words whitespace-normal text-lg">
<span class="mx-2">
Your Notion files has been successfully imported. Please open AppFlowy and switch to the new workspace
</span>
<br>
<span class="text-2xl font-bold">
{{ workspaceName }}
</span>
</p>
<x-divider space-x="20%" />
</td>
</tr>
<tr>
<td class="text-center text-slate-600 text-xs px-6">
<p class="m-0 mb-4 uppercase cursor-pointer">
<a href="https://appflowy.io">
<img
src="{{ cdnBaseUrl }}images/appflowy-logo.png"
width="150px"
/>
</a>
</p>
</td>
</tr>

View file

@ -242,6 +242,22 @@ pub async fn select_name_from_uuid(pool: &PgPool, user_uuid: &Uuid) -> Result<St
Ok(email)
}
pub async fn select_name_and_email_from_uuid(
pool: &PgPool,
user_uuid: &Uuid,
) -> Result<(String, String), AppError> {
let row = sqlx::query!(
r#"
SELECT name, email FROM af_user WHERE uuid = $1
"#,
user_uuid
)
.fetch_one(pool)
.await?;
Ok((row.name, row.email))
}
pub async fn select_web_user_from_uid(pool: &PgPool, uid: i64) -> Result<AFWebUser, AppError> {
let row = sqlx::query_as!(
AFWebUser,

13
libs/mailer/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "mailer"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
lettre = { version = "0.11.7", features = ["tokio1", "tokio1-native-tls"] }
anyhow.workspace = true
serde.workspace = true
handlebars = "5.1.2"
secrecy.workspace = true

View file

@ -0,0 +1,9 @@
use secrecy::Secret;
#[derive(serde::Deserialize, Clone, Debug)]
pub struct MailerSetting {
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_username: String,
pub smtp_password: Secret<String>,
}

2
libs/mailer/src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod config;
pub mod sender;

80
libs/mailer/src/sender.rs Normal file
View file

@ -0,0 +1,80 @@
use handlebars::Handlebars;
use lettre::message::header::ContentType;
use lettre::message::Message;
use lettre::transport::smtp::authentication::Credentials;
use lettre::Address;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
#[derive(Clone)]
pub struct Mailer {
smtp_transport: AsyncSmtpTransport<lettre::Tokio1Executor>,
smtp_username: String,
handlers: Handlebars<'static>,
}
impl Mailer {
pub async fn new(
smtp_username: String,
smtp_password: String,
smtp_host: &str,
smtp_port: u16,
) -> Result<Self, anyhow::Error> {
let creds = Credentials::new(smtp_username.clone(), smtp_password);
let smtp_transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(smtp_host)?
.credentials(creds)
.port(smtp_port)
.build();
let handlers = Handlebars::new();
Ok(Self {
smtp_transport,
smtp_username,
handlers,
})
}
pub async fn register_template(
&mut self,
name: &str,
template: &str,
) -> Result<(), anyhow::Error> {
self.handlers.register_template_string(name, template)?;
Ok(())
}
pub fn render<T>(&self, name: &str, param: &T) -> Result<String, anyhow::Error>
where
T: serde::Serialize,
{
let rendered = self.handlers.render(name, param)?;
Ok(rendered)
}
pub async fn send_email_template<T>(
&self,
recipient_name: Option<String>,
email: &str,
template_name: &str,
param: T,
subject: &str,
) -> Result<(), anyhow::Error>
where
T: serde::Serialize,
{
let rendered = self.handlers.render(template_name, &param)?;
let email = Message::builder()
.from(lettre::message::Mailbox::new(
Some("AppFlowy Notification".to_string()),
self.smtp_username.parse::<Address>()?,
))
.to(lettre::message::Mailbox::new(
recipient_name,
email.parse()?,
))
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(rendered)?;
AsyncTransport::send(&self.smtp_transport, email).await?;
Ok(())
}
}

View file

@ -45,5 +45,6 @@ async_zip = { version = "0.0.17", features = ["full"] }
mime_guess = "2.0"
bytes.workspace = true
uuid.workspace = true
mailer.workspace = true

View file

@ -13,6 +13,8 @@ use crate::s3_client::S3ClientImpl;
use axum::Router;
use secrecy::ExposeSecret;
use crate::mailer::AFWorkerMailer;
use mailer::sender::Mailer;
use std::sync::{Arc, Once};
use std::time::Duration;
use tokio::net::TcpListener;
@ -75,22 +77,24 @@ pub async fn create_app(listener: TcpListener, config: Config) -> Result<(), Err
let pg_pool = get_connection_pool(&config.db_settings).await?;
// Redis
let redis_client = redis::Client::open(config.redis_url)
let redis_client = redis::Client::open(config.redis_url.clone())
.expect("failed to create redis client")
.get_connection_manager()
.await
.expect("failed to get redis connection manager");
let mailer = get_worker_mailer(&config).await?;
let s3_client = get_aws_s3_client(&config.s3_setting).await?;
let state = AppState {
redis_client,
pg_pool,
s3_client,
mailer: mailer.clone(),
};
let local_set = LocalSet::new();
let email_notifier = EmailNotifier;
let email_notifier = EmailNotifier::new(mailer);
let import_worker_fut = local_set.run_until(run_import_worker(
state.pg_pool.clone(),
state.redis_client.clone(),
@ -119,6 +123,19 @@ pub struct AppState {
pub redis_client: ConnectionManager,
pub pg_pool: PgPool,
pub s3_client: S3ClientImpl,
pub mailer: AFWorkerMailer,
}
async fn get_worker_mailer(config: &Config) -> Result<AFWorkerMailer, Error> {
let mailer = Mailer::new(
config.mailer.smtp_username.clone(),
config.mailer.smtp_password.expose_secret().clone(),
&config.mailer.smtp_host,
config.mailer.smtp_port,
)
.await?;
AFWorkerMailer::new(mailer).await
}
async fn get_connection_pool(setting: &DatabaseSetting) -> Result<PgPool, Error> {

View file

@ -1,5 +1,6 @@
use anyhow::{Context, Error};
use infra::env_util::get_env_var;
use mailer::config::MailerSetting;
use secrecy::Secret;
use serde::Deserialize;
use sqlx::postgres::{PgConnectOptions, PgSslMode};
@ -11,6 +12,7 @@ pub struct Config {
pub redis_url: String,
pub db_settings: DatabaseSetting,
pub s3_setting: S3Setting,
pub mailer: MailerSetting,
}
impl Config {
@ -43,6 +45,12 @@ impl Config {
bucket: get_env_var("APPFLOWY_S3_BUCKET", "appflowy"),
region: get_env_var("APPFLOWY_S3_REGION", ""),
},
mailer: MailerSetting {
smtp_host: get_env_var("APPFLOWY_MAILER_SMTP_HOST", "smtp.gmail.com"),
smtp_port: get_env_var("APPFLOWY_MAILER_SMTP_PORT", "465").parse()?,
smtp_username: get_env_var("APPFLOWY_MAILER_SMTP_USERNAME", "sender@example.com"),
smtp_password: get_env_var("APPFLOWY_MAILER_SMTP_PASSWORD", "password").into(),
},
})
}
}

View file

@ -1,3 +1,4 @@
pub use collab_importer::error::ImporterError as CollabImporterError;
#[derive(thiserror::Error, Debug)]
pub enum WorkerError {
#[error(transparent)]
@ -18,11 +19,8 @@ pub enum WorkerError {
#[derive(thiserror::Error, Debug)]
pub enum ImportError {
#[error("Can not open the imported workspace: {0}")]
OpenImportWorkspaceError(String),
#[error(transparent)]
ImportCollabError(#[from] collab_importer::error::ImporterError),
ImportCollabError(#[from] CollabImporterError),
#[error("Can not open the workspace:{0}")]
CannotOpenWorkspace(String),
@ -30,3 +28,103 @@ pub enum ImportError {
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
impl ImportError {
pub fn report(&self, task_id: &str) -> (String, String) {
match self {
ImportError::ImportCollabError(error) => match error {
CollabImporterError::InvalidPath(s) => (
format!(
"Task ID: {} - The provided file path is invalid. Please check the path and try again.",
task_id
),
format!("Task ID: {} - Invalid path: {}", task_id, s),
),
CollabImporterError::InvalidPathFormat => (
format!(
"Task ID: {} - The file path format is incorrect. Please ensure it is in the correct format.",
task_id
),
format!("Task ID: {} - Invalid path format", task_id),
),
CollabImporterError::InvalidFileType(file_type) => (
format!(
"Task ID: {} - The file type is unsupported. Please use a supported file type.",
task_id
),
format!("Task ID: {} - Invalid file type: {}", task_id, file_type),
),
CollabImporterError::ImportMarkdownError(_) => (
format!(
"Task ID: {} - There was an issue importing the markdown file. Please verify the file contents.",
task_id
),
format!("Task ID: {} - Import markdown error", task_id),
),
CollabImporterError::ImportCsvError(_) => (
format!(
"Task ID: {} - There was an issue importing the CSV file. Please ensure it is correctly formatted.",
task_id
),
format!("Task ID: {} - Import CSV error", task_id),
),
CollabImporterError::ParseMarkdownError(_) => (
format!(
"Task ID: {} - Failed to parse the markdown file. Please check for any formatting issues.",
task_id
),
format!("Task ID: {} - Parse markdown error", task_id),
),
CollabImporterError::Utf8Error(_) => (
format!(
"Task ID: {} - There was a character encoding issue. Ensure your file is in UTF-8 format.",
task_id
),
format!("Task ID: {} - UTF-8 error", task_id),
),
CollabImporterError::IOError(_) => (
format!(
"Task ID: {} - An input/output error occurred. Please check your file and try again.",
task_id
),
format!("Task ID: {} - IO error", task_id),
),
CollabImporterError::FileNotFound => (
format!(
"Task ID: {} - The specified file could not be found. Please check the file path.",
task_id
),
format!("Task ID: {} - File not found", task_id),
),
CollabImporterError::CannotImport => (
format!(
"Task ID: {} - The file could not be imported. Please ensure it is in a valid format.",
task_id
),
format!("Task ID: {} - Cannot import file", task_id),
),
CollabImporterError::Internal(_) => (
format!(
"Task ID: {} - An internal error occurred during the import process. Please try again later.",
task_id
),
format!("Task ID: {} - Internal error", task_id),
),
},
ImportError::CannotOpenWorkspace(err) => (
format!(
"Task ID: {} - Unable to open the workspace. Please verify the workspace and try again.",
task_id
),
format!("Task ID: {} - Cannot open workspace: {}", task_id, err),
),
ImportError::Internal(err) => (
format!(
"Task ID: {} - An internal error occurred. Please try again or contact support.",
task_id
),
format!("Task ID: {} - Internal error: {}", task_id, err),
),
}
}
}

View file

@ -1,14 +1,48 @@
use crate::import_worker::report::{ImportNotifier, ImportProgress};
use crate::mailer::{AFWorkerMailer, IMPORT_FAIL_TEMPLATE, IMPORT_SUCCESS_TEMPLATE};
use axum::async_trait;
use tracing::{error, trace};
pub struct EmailNotifier;
pub struct EmailNotifier(AFWorkerMailer);
impl EmailNotifier {
pub fn new(mailer: AFWorkerMailer) -> Self {
Self(mailer)
}
}
#[async_trait]
impl ImportNotifier for EmailNotifier {
async fn notify_progress(&self, progress: ImportProgress) {
match progress {
ImportProgress::Started { workspace_id: _ } => {},
ImportProgress::Finished(_result) => {},
ImportProgress::Finished(result) => {
let subject = "Notification: Import Report";
trace!(
"[Import]: sending import notion report email to {}, params: {:?}",
result.user_email,
result,
);
let template_name = if result.is_success {
IMPORT_SUCCESS_TEMPLATE
} else {
IMPORT_FAIL_TEMPLATE
};
if let Err(err) = self
.0
.send_email_template(
Some(result.user_name),
&result.user_email,
template_name,
result.value,
subject,
)
.await
{
error!("Failed to send import notion report email: {}", err);
}
},
}
}
}

View file

@ -1,3 +1,4 @@
use axum::async_trait;
#[async_trait]
@ -13,21 +14,8 @@ pub enum ImportProgress {
#[derive(Debug, Clone)]
pub struct ImportResult {
pub workspace_id: String,
}
pub struct ImportResultBuilder {
workspace_id: String,
}
impl ImportResultBuilder {
pub fn new(workspace_id: String) -> Self {
Self { workspace_id }
}
pub fn build(self) -> ImportResult {
ImportResult {
workspace_id: self.workspace_id,
}
}
pub user_name: String,
pub user_email: String,
pub is_success: bool,
pub value: serde_json::Value,
}

View file

@ -1,4 +1,4 @@
use crate::import_worker::report::{ImportNotifier, ImportProgress, ImportResultBuilder};
use crate::import_worker::report::{ImportNotifier, ImportProgress, ImportResult};
use crate::import_worker::unzip::unzip_async;
use crate::s3_client::S3StreamResponse;
use anyhow::anyhow;
@ -52,6 +52,7 @@ use tokio::task::spawn_local;
use tokio::time::interval;
use crate::error::ImportError;
use crate::mailer::ImportNotionMailerParam;
use database::resource_usage::{insert_blob_metadata_bulk, BulkInsertMeta};
use tracing::{error, info, trace, warn};
use uuid::Uuid;
@ -277,17 +278,15 @@ async fn process_task(
},
ImportTask::Custom(value) => {
trace!("Custom task: {:?}", value);
match value.get("workspace_id").and_then(|v| v.as_str()) {
None => {
warn!("Missing workspace_id in custom task");
},
Some(workspace_id) => {
let result = ImportResultBuilder::new(workspace_id.to_string()).build();
notifier
.notify_progress(ImportProgress::Finished(result))
.await;
},
}
let result = ImportResult {
user_name: "".to_string(),
user_email: "".to_string(),
is_success: true,
value: Default::default(),
};
notifier
.notify_progress(ImportProgress::Finished(result))
.await;
Ok(())
},
}
@ -645,20 +644,46 @@ async fn remove_workspace(workspace_id: &str, pg_pool: &PgPool) {
async fn notify_user(
import_task: &NotionImportTask,
result: Result<(), ImportError>,
_notifier: Arc<dyn ImportNotifier>,
notifier: Arc<dyn ImportNotifier>,
) -> Result<(), ImportError> {
match result {
let task_id = import_task.task_id.to_string();
let (error, error_detail) = match result {
Ok(_) => {
info!("[Import]: successfully imported:{}", import_task);
(None, None)
},
Err(err) => {
error!(
"[Import]: failed to import:{}: error:{:?}",
import_task, err
);
let (error, error_detail) = err.report(&task_id);
(Some(error), Some(error_detail))
},
}
// send email
};
let is_success = error.is_none();
let value = serde_json::to_value(ImportNotionMailerParam {
import_task_id: task_id,
user_name: import_task.user_name.clone(),
import_file_name: import_task.workspace_name.clone(),
workspace_id: import_task.workspace_id.clone(),
workspace_name: import_task.workspace_name.clone(),
open_workspace: false,
error,
error_detail,
})
.unwrap();
notifier
.notify_progress(ImportProgress::Finished(ImportResult {
user_name: import_task.user_name.clone(),
user_email: import_task.user_email.clone(),
is_success,
value,
}))
.await;
Ok(())
}
@ -812,8 +837,9 @@ async fn get_un_ack_tasks(
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotionImportTask {
pub uid: i64,
pub user_name: String,
pub user_email: String,
pub task_id: Uuid,
pub user_uuid: String,
pub workspace_id: String,
pub workspace_name: String,
pub s3_key: String,
@ -823,8 +849,8 @@ impl Display for NotionImportTask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"NotionImportTask {{ workspace_id: {}, workspace_name: {} }}",
self.workspace_id, self.workspace_name
"NotionImportTask {{ task_id: {}, workspace_id: {}, workspace_name: {}, user_name: {}, user_email: {} }}",
self.task_id, self.workspace_id, self.workspace_name, self.user_name, self.user_email
)
}
}

View file

@ -1,3 +1,4 @@
pub mod error;
pub mod import_worker;
mod mailer;
pub mod s3_client;

View file

@ -0,0 +1,86 @@
use mailer::sender::Mailer;
use std::ops::Deref;
pub const IMPORT_SUCCESS_TEMPLATE: &str = "import_notion_success";
pub const IMPORT_FAIL_TEMPLATE: &str = "import_notion_fail";
#[derive(Clone)]
pub struct AFWorkerMailer(Mailer);
impl Deref for AFWorkerMailer {
type Target = Mailer;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AFWorkerMailer {
pub async fn new(mut mailer: Mailer) -> Result<Self, anyhow::Error> {
let import_data_success =
include_str!("../../../assets/mailer_templates/build_production/import_data_success.html");
let import_data_fail =
include_str!("../../../assets/mailer_templates/build_production/import_data_fail.html");
for (name, template) in [
(IMPORT_SUCCESS_TEMPLATE, import_data_success),
(IMPORT_FAIL_TEMPLATE, import_data_fail),
] {
mailer
.register_template(name, template)
.await
.map_err(|err| {
anyhow::anyhow!(format!("Failed to register handlebars template: {}", err))
})?;
}
Ok(Self(mailer))
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ImportNotionMailerParam {
pub import_task_id: String,
pub user_name: String,
pub import_file_name: String,
pub workspace_id: String,
pub workspace_name: String,
pub open_workspace: bool,
pub error: Option<String>,
pub error_detail: Option<String>,
}
#[cfg(test)]
mod tests {
use crate::mailer::{AFWorkerMailer, ImportNotionMailerParam, IMPORT_SUCCESS_TEMPLATE};
use mailer::sender::Mailer;
#[tokio::test]
async fn render_import_report() {
let mailer = Mailer::new(
"test mailer".to_string(),
"123".to_string(),
"localhost",
465,
)
.await
.unwrap();
let worker_mailer = AFWorkerMailer::new(mailer).await.unwrap();
let value = serde_json::to_value(ImportNotionMailerParam {
import_task_id: "test_task_id".to_string(),
user_name: "nathan".to_string(),
import_file_name: "working".to_string(),
workspace_id: "1".to_string(),
workspace_name: "working".to_string(),
open_workspace: true,
error: None,
error_detail: None,
})
.unwrap();
let s = worker_mailer
.render(IMPORT_SUCCESS_TEMPLATE, &value)
.unwrap();
println!("{}", s);
}
}

View file

@ -4,12 +4,15 @@ pub mod error;
pub mod import_worker;
pub(crate) mod s3_client;
mod mailer;
use crate::application::run_server;
use crate::config::Config;
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenvy::dotenv().ok();
let listener = TcpListener::bind("0.0.0.0:4001").await.unwrap();
let config = Config::from_env().expect("failed to load config");
run_server(listener, config).await

View file

@ -48,11 +48,9 @@ async fn create_custom_task_test(pg_pool: PgPool) {
let mut rx = notifier.subscribe();
timeout(Duration::from_secs(30), async {
while let Ok(task) = rx.recv().await {
task_workspace_ids.retain(|id| {
if let ImportProgress::Finished(result) = &task {
if result.workspace_id == *id {
return false;
}
task_workspace_ids.retain(|_id| {
if let ImportProgress::Finished(_result) = &task {
return false;
}
true
});

View file

@ -9,6 +9,7 @@ use aws_sdk_s3::primitives::ByteStream;
use database::file::BucketClient;
use crate::biz::workspace::ops::{create_empty_workspace, create_upload_task};
use database::user::select_name_and_email_from_uuid;
use database::workspace::select_import_task;
use futures_util::StreamExt;
use shared_entity::dto::import_dto::{ImportTaskDetail, ImportTaskStatus, UserImportTask};
@ -63,6 +64,7 @@ async fn import_data_handler(
req: HttpRequest,
) -> actix_web::Result<JsonAppResponse<()>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let (user_name, user_email) = select_name_and_email_from_uuid(&state.pg_pool, &user_uuid).await?;
let host = get_host_from_request(&req);
let content_length = req
.headers()
@ -147,7 +149,8 @@ async fn import_data_handler(
create_upload_task(
uid,
&user_uuid,
&user_name,
&user_email,
&workspace_id,
&workspace_name,
file_size,

View file

@ -45,6 +45,7 @@ use appflowy_collaborate::snapshot::SnapshotControl;
use appflowy_collaborate::CollaborationServer;
use database::file::s3_client_impl::{AwsS3BucketClientImpl, S3BucketStorage};
use gotrue::grant::{Grant, PasswordGrant};
use mailer::sender::Mailer;
use snowflake::Snowflake;
use tonic_proto::history::history_client::HistoryClient;
@ -70,7 +71,7 @@ use crate::biz::workspace::publish::{
use crate::config::config::{
Config, DatabaseSetting, GoTrueSetting, PublishedCollabStorageBackend, S3Setting,
};
use crate::mailer::Mailer;
use crate::mailer::AFCloudMailer;
use crate::middleware::access_control_mw::MiddlewareAccessControlTransform;
use crate::middleware::metrics_mw::MetricsMiddleware;
use crate::middleware::request_id::RequestIdMiddleware;
@ -335,13 +336,7 @@ pub async fn init_state(config: &Config, rt_cmd_tx: CLCommandSender) -> Result<A
.connect_lazy();
let grpc_history_client = Arc::new(Mutex::new(HistoryClient::new(channel)));
let mailer = Mailer::new(
config.mailer.smtp_username.clone(),
config.mailer.smtp_password.expose_secret().clone(),
&config.mailer.smtp_host,
config.mailer.smtp_port,
)
.await?;
let mailer = get_mailer(config).await?;
let realtime_shared_state = RealtimeSharedState::new(redis_conn_manager.clone());
if let Err(err) = realtime_shared_state.remove_all_connected_users().await {
warn!("Failed to remove all connected users: {:?}", err);
@ -541,6 +536,18 @@ async fn create_bucket_if_not_exists(
}
}
async fn get_mailer(config: &Config) -> Result<AFCloudMailer, Error> {
let mailer = Mailer::new(
config.mailer.smtp_username.clone(),
config.mailer.smtp_password.expose_secret().clone(),
&config.mailer.smtp_host,
config.mailer.smtp_port,
)
.await?;
AFCloudMailer::new(mailer).await
}
async fn get_connection_pool(setting: &DatabaseSetting) -> Result<PgPool, Error> {
info!("Connecting to postgres database with setting: {}", setting);
PgPoolOptions::new()

View file

@ -1,5 +1,13 @@
use std::{ops::DerefMut, sync::Arc};
use crate::mailer::AFCloudMailer;
use crate::{
biz::collab::{
folder_view::{to_dto_view_icon, to_view_layout},
ops::get_latest_collab_folder,
},
mailer::{WorkspaceAccessRequestApprovedMailerParam, WorkspaceAccessRequestMailerParam},
};
use anyhow::Context;
use app_error::AppError;
use appflowy_collaborate::collab::storage::CollabAccessControlStorage;
@ -16,17 +24,9 @@ use shared_entity::dto::access_request_dto::{AccessRequest, AccessRequestView};
use sqlx::PgPool;
use uuid::Uuid;
use crate::{
biz::collab::{
folder_view::{to_dto_view_icon, to_view_layout},
ops::get_latest_collab_folder,
},
mailer::{Mailer, WorkspaceAccessRequestApprovedMailerParam, WorkspaceAccessRequestMailerParam},
};
pub async fn create_access_request(
pg_pool: &PgPool,
mailer: Mailer,
mailer: AFCloudMailer,
appflowy_web_url: &str,
workspace_id: Uuid,
view_id: Uuid,
@ -118,7 +118,7 @@ pub async fn get_access_request(
pub async fn approve_or_reject_access_request(
pg_pool: &PgPool,
mailer: Mailer,
mailer: AFCloudMailer,
appflowy_web_url: &str,
request_id: Uuid,
uid: i64,

View file

@ -1,4 +1,4 @@
use authentication::jwt::{OptionalUserUuid, UserUuid};
use authentication::jwt::OptionalUserUuid;
use database_entity::dto::AFWorkspaceSettingsChange;
use std::collections::HashMap;
@ -26,6 +26,7 @@ use database_entity::dto::{
AFWorkspaceSettings, GlobalComment, Reaction, WorkspaceUsage,
};
use gotrue::params::{GenerateLinkParams, GenerateLinkType};
use shared_entity::dto::workspace_dto::{
CreateWorkspaceMember, WorkspaceMemberChangeset, WorkspaceMemberInvitation,
};
@ -36,7 +37,7 @@ use crate::biz::user::user_init::{
create_user_awareness, create_workspace_collab, create_workspace_database_collab,
initialize_workspace_for_user,
};
use crate::mailer::{Mailer, WorkspaceInviteMailerParam};
use crate::mailer::{AFCloudMailer, WorkspaceInviteMailerParam};
use crate::state::{GoTrueAdmin, RedisConnectionManager};
const MAX_COMMENT_LENGTH: usize = 5000;
@ -330,7 +331,7 @@ pub async fn accept_workspace_invite(
#[instrument(level = "debug", skip_all, err)]
#[allow(clippy::too_many_arguments)]
pub async fn invite_workspace_members(
mailer: &Mailer,
mailer: &AFCloudMailer,
gotrue_admin: &GoTrueAdmin,
pg_pool: &PgPool,
gotrue_client: &gotrue::api::Client,
@ -697,7 +698,8 @@ async fn check_if_user_is_allowed_to_delete_comment(
#[allow(clippy::too_many_arguments)]
pub async fn create_upload_task(
uid: i64,
user_uuid: &UserUuid,
user_name: &str,
user_email: &str,
workspace_id: &str,
workspace_name: &str,
file_size: usize,
@ -722,7 +724,8 @@ pub async fn create_upload_task(
let task = json!({
"notion": {
"uid": uid,
"user_uuid": user_uuid,
"user_name": user_name,
"user_email": user_email,
"task_id": task_id,
"workspace_id": workspace_id,
"s3_key": workspace_id,

View file

@ -8,6 +8,7 @@ use serde::Deserialize;
use sqlx::postgres::{PgConnectOptions, PgSslMode};
use infra::env_util::{get_env_var, get_env_var_opt};
use mailer::config::MailerSetting;
#[derive(Clone, Debug)]
pub struct Config {
@ -29,18 +30,11 @@ pub struct Config {
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct AccessControlSetting {
pub is_enabled: bool,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct MailerSetting {
pub smtp_host: String,
pub smtp_port: u16,
pub smtp_username: String,
pub smtp_password: Secret<String>,
}
#[derive(serde::Deserialize, Clone, Debug)]
pub struct AppleOAuthSetting {
pub client_id: String,

View file

@ -1,109 +1,19 @@
use lettre::message::header::ContentType;
use lettre::message::Message;
use lettre::transport::smtp::authentication::Credentials;
use lettre::Address;
use lettre::AsyncSmtpTransport;
use lettre::AsyncTransport;
use mailer::sender::Mailer;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
lazy_static::lazy_static! {
static ref HANDLEBARS: Arc<RwLock<handlebars::Handlebars<'static>>> =
Arc::new(handlebars::Handlebars::new().into());
}
#[derive(Clone)]
pub struct Mailer {
smtp_transport: AsyncSmtpTransport<lettre::Tokio1Executor>,
smtp_username: String,
}
pub const WORKSPACE_INVITE_TEMPLATE_NAME: &str = "workspace_invite";
pub const WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME: &str = "workspace_access_request";
pub const WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME: &str =
"workspace_access_request_approved_notification";
impl Mailer {
pub async fn new(
smtp_username: String,
smtp_password: String,
smtp_host: &str,
smtp_port: u16,
) -> Result<Self, anyhow::Error> {
let creds = Credentials::new(smtp_username.clone(), smtp_password);
let smtp_transport = AsyncSmtpTransport::<lettre::Tokio1Executor>::relay(smtp_host)?
.credentials(creds)
.port(smtp_port)
.build();
let workspace_invite_template =
include_str!("../assets/mailer_templates/build_production/workspace_invitation.html");
let access_request_template =
include_str!("../assets/mailer_templates/build_production/access_request.html");
let access_request_approved_notification_template = include_str!(
"../assets/mailer_templates/build_production/access_request_approved_notification.html"
);
let template_strings = HashMap::from([
(WORKSPACE_INVITE_TEMPLATE_NAME, workspace_invite_template),
(
WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,
access_request_template,
),
(
WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME,
access_request_approved_notification_template,
),
]);
for (template_name, template_string) in template_strings {
HANDLEBARS
.write()
.map_err(|err| anyhow::anyhow!(format!("Failed to write handlebars: {}", err)))?
.register_template_string(template_name, template_string)
.map_err(|err| {
anyhow::anyhow!(format!("Failed to register handlebars template: {}", err))
})?;
}
Ok(Self {
smtp_transport,
smtp_username,
})
#[derive(Clone)]
pub struct AFCloudMailer(Mailer);
impl AFCloudMailer {
pub async fn new(mut mailer: Mailer) -> Result<Self, anyhow::Error> {
register_mailer(&mut mailer).await?;
Ok(Self(mailer))
}
async fn send_email_template<T>(
&self,
recipient_name: Option<String>,
email: &str,
template_name: &str,
param: T,
subject: &str,
) -> Result<(), anyhow::Error>
where
T: serde::Serialize,
{
let rendered = match HANDLEBARS.read() {
Ok(registory) => registory.render(template_name, &param)?,
Err(err) => anyhow::bail!(format!("Failed to render handlebars template: {}", err)),
};
let email = Message::builder()
.from(lettre::message::Mailbox::new(
Some("AppFlowy Notification".to_string()),
self.smtp_username.parse::<Address>()?,
))
.to(lettre::message::Mailbox::new(
recipient_name,
email.parse()?,
))
.subject(subject)
.header(ContentType::TEXT_HTML)
.body(rendered)?;
AsyncTransport::send(&self.smtp_transport, email).await?;
Ok(())
}
pub async fn send_workspace_invite(
&self,
email: &str,
@ -114,6 +24,7 @@ impl Mailer {
param.username, param.workspace_name
);
self
.0
.send_email_template(
Some(param.username.clone()),
email,
@ -135,6 +46,7 @@ impl Mailer {
param.username, param.workspace_name
);
self
.0
.send_email_template(
Some(recipient_name.to_string()),
email,
@ -153,6 +65,7 @@ impl Mailer {
) -> Result<(), anyhow::Error> {
let subject = "Notification: Workspace access request approved";
self
.0
.send_email_template(
Some(recipient_name.to_string()),
email,
@ -164,6 +77,36 @@ impl Mailer {
}
}
async fn register_mailer(mailer: &mut Mailer) -> Result<(), anyhow::Error> {
let workspace_invite_template =
include_str!("../assets/mailer_templates/build_production/workspace_invitation.html");
let access_request_template =
include_str!("../assets/mailer_templates/build_production/access_request.html");
let access_request_approved_notification_template = include_str!(
"../assets/mailer_templates/build_production/access_request_approved_notification.html"
);
let template_strings = HashMap::from([
(WORKSPACE_INVITE_TEMPLATE_NAME, workspace_invite_template),
(
WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,
access_request_template,
),
(
WORKSPACE_ACCESS_REQUEST_APPROVED_NOTIFICATION_TEMPLATE_NAME,
access_request_approved_notification_template,
),
]);
for (template_name, template_string) in template_strings {
mailer
.register_template(template_name, template_string)
.await
.map_err(|err| anyhow::anyhow!(format!("Failed to register handlebars template: {}", err)))?;
}
Ok(())
}
#[derive(serde::Serialize)]
pub struct WorkspaceInviteMailerParam {
pub user_icon_url: String,

View file

@ -22,6 +22,7 @@ use appflowy_collaborate::CollabRealtimeMetrics;
use database::file::s3_client_impl::{AwsS3BucketClientImpl, S3BucketStorage};
use database::user::{select_all_uid_uuid, select_uid_from_uuid};
use gotrue::grant::{Grant, PasswordGrant};
use snowflake::Snowflake;
use tonic_proto::history::history_client::HistoryClient;
@ -29,7 +30,7 @@ use crate::api::metrics::{PublishedCollabMetrics, RequestMetrics};
use crate::biz::pg_listener::PgListeners;
use crate::biz::workspace::publish::PublishedCollabStore;
use crate::config::config::Config;
use crate::mailer::Mailer;
use crate::mailer::AFCloudMailer;
pub type RedisConnectionManager = redis::aio::ConnectionManager;
#[derive(Clone)]
@ -51,7 +52,7 @@ pub struct AppState {
pub pg_listeners: Arc<PgListeners>,
pub metrics: AppMetrics,
pub gotrue_admin: GoTrueAdmin,
pub mailer: Mailer,
pub mailer: AFCloudMailer,
pub ai_client: AppFlowyAIClient,
pub realtime_shared_state: RealtimeSharedState,
pub grpc_history_client: Arc<Mutex<HistoryClient<tonic::transport::Channel>>>,