mirror of
https://github.com/AppFlowy-IO/AppFlowy-Cloud.git
synced 2025-04-19 03:24:42 -04:00
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:
parent
ec2cc309bd
commit
bbd21dfef6
35 changed files with 809 additions and 184 deletions
28
.sqlx/query-24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716.json
generated
Normal file
28
.sqlx/query-24c5fb37a4391d590e83d2710e9a2ee7f4d06efcdd6034df1f67bb0d9db45716.json
generated
Normal 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
17
Cargo.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</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>
|
|
@ -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
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</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%">‍</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>
|
2
dev.env
2
dev.env
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 }}",
|
||||
|
|
50
email_template/src/templates/import_data_fail.html
Normal file
50
email_template/src/templates/import_data_fail.html
Normal 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>
|
39
email_template/src/templates/import_data_success.html
Normal file
39
email_template/src/templates/import_data_success.html
Normal 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>
|
||||
|
|
@ -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
13
libs/mailer/Cargo.toml
Normal 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
|
9
libs/mailer/src/config.rs
Normal file
9
libs/mailer/src/config.rs
Normal 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
2
libs/mailer/src/lib.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod config;
|
||||
pub mod sender;
|
80
libs/mailer/src/sender.rs
Normal file
80
libs/mailer/src/sender.rs
Normal 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, ¶m)?;
|
||||
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(())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod error;
|
||||
pub mod import_worker;
|
||||
mod mailer;
|
||||
pub mod s3_client;
|
||||
|
|
86
services/appflowy-worker/src/mailer.rs
Normal file
86
services/appflowy-worker/src/mailer.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
137
src/mailer.rs
137
src/mailer.rs
|
@ -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, ¶m)?,
|
||||
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,
|
||||
|
|
|
@ -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>>>,
|
||||
|
|
Loading…
Add table
Reference in a new issue