Merge pull request #848 from AppFlowy-IO/send-approval-email
feat: Send email to owner when user request workspace access
This commit is contained in:
commit
96d7ae8b95
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
echo ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} | docker login --username appflowyinc --password-stdin
|
||||
docker push appflowyinc/appflowy_cloud:${GITHUB_SHA}
|
||||
docker push appflowyinc/appflowy_history:${GITHUB_SHA}
|
||||
APPFLOWY_HISTORY_VERSION=${GITHUB_SHA}
|
||||
APPFLOWY_HISTORY_VERSION=${GITHUB_SHA}
|
||||
APPFLOWY_CLOUD_VERSION=0.1.1
|
||||
|
||||
test:
|
||||
|
|
@ -93,6 +93,7 @@ jobs:
|
|||
sed -i "s|LOCAL_AI_AWS_SECRET_ACCESS_KEY=.*|LOCAL_AI_AWS_SECRET_ACCESS_KEY=${{ secrets.LOCAL_AI_AWS_SECRET_ACCESS_KEY }}|" .env
|
||||
sed -i 's|APPFLOWY_INDEXER_REDIS_URL=.*|APPFLOWY_INDEXER_REDIS_URL=redis://localhost:6379|' .env
|
||||
sed -i 's|APPFLOWY_INDEXER_DATABASE_URL=.*|APPFLOWY_INDEXER_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres|' .env
|
||||
sed -i 's|APPFLOWY_WEB_URL=.*|APPFLOWY_WEB_URL=http://localhost:3000|' .env
|
||||
shell: bash
|
||||
|
||||
- name: Update Nginx Configuration
|
||||
|
|
@ -125,4 +126,4 @@ jobs:
|
|||
- name: Remove Docker Images from Docker Hub
|
||||
run: |
|
||||
TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username": "appflowyinc", "password": "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}"}' https://hub.docker.com/v2/users/login/ | jq -r .token)
|
||||
curl -s -X DELETE -H "Authorization: JWT ${TOKEN}" https://hub.docker.com/v2/repositories/appflowyinc/${{ matrix.test_service }}/tags/${GITHUB_SHA}/
|
||||
curl -s -X DELETE -H "Authorization: JWT ${TOKEN}" https://hub.docker.com/v2/repositories/appflowyinc/${{ matrix.test_service }}/tags/${GITHUB_SHA}/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH request_id_workspace_member_count AS (\n SELECT\n request_id,\n COUNT(*) AS member_count\n FROM af_access_request\n JOIN af_workspace_member USING (workspace_id)\n WHERE request_id = $1\n GROUP BY request_id\n )\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon,\n request_id_workspace_member_count.member_count\n ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n (\n af_user.uuid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN request_id_workspace_member_count USING (request_id)\n WHERE request_id = $1\n ",
|
||||
"query": "\n WITH request_id_workspace_member_count AS (\n SELECT\n request_id,\n COUNT(*) AS member_count\n FROM af_access_request\n JOIN af_workspace_member USING (workspace_id)\n WHERE request_id = $1\n GROUP BY request_id\n )\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n owner_profile.email,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon,\n request_id_workspace_member_count.member_count\n ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n (\n af_user.uuid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN request_id_workspace_member_count USING (request_id)\n WHERE request_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -48,5 +48,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "343cdf36e68c8333ecc6b778789d8de543c15f2aa0318dac2d10c5f1ef0f0232"
|
||||
"hash": "0c3ae560880e82218d13c5992540386ea1566e45e31acd5fb51886aabcd98479"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO public.af_workspace (owner_uid, workspace_name)\n VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2)\n RETURNING\n workspace_id,\n database_storage_id,\n owner_uid,\n (SELECT name FROM public.af_user WHERE uid = owner_uid) AS owner_name,\n created_at,\n workspace_type,\n deleted_at,\n workspace_name,\n icon\n ;\n ",
|
||||
"query": "\n WITH new_workspace AS (\n INSERT INTO public.af_workspace (owner_uid, workspace_name)\n VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2)\n RETURNING *\n )\n SELECT\n workspace_id,\n database_storage_id,\n owner_uid,\n owner_profile.name AS owner_name,\n owner_profile.email AS owner_email,\n new_workspace.created_at,\n workspace_type,\n new_workspace.deleted_at,\n workspace_name,\n icon\n FROM new_workspace\n JOIN public.af_user AS owner_profile ON new_workspace.owner_uid = owner_profile.uid;\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -25,26 +25,31 @@
|
|||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "workspace_type",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"name": "workspace_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"name": "icon",
|
||||
"type_info": "Text"
|
||||
}
|
||||
|
|
@ -59,7 +64,8 @@
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
|
|
@ -67,5 +73,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "29a6f76da0baf71c215b69078cce66d55f43d63f5c1c9e6786a4e80b52b4c5df"
|
||||
"hash": "cf7b8baaba35e74671911e13f1efcdfa3a642d2b7276c2a81f877a6217a0d473"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n w.workspace_id,\n w.database_storage_id,\n w.owner_uid,\n (SELECT name FROM public.af_user WHERE uid = w.owner_uid) AS owner_name,\n w.created_at,\n w.workspace_type,\n w.deleted_at,\n w.workspace_name,\n w.icon\n FROM af_workspace w\n JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n WHERE wm.uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n );\n ",
|
||||
"query": "\n SELECT\n w.workspace_id,\n w.database_storage_id,\n w.owner_uid,\n u.name AS owner_name,\n u.email AS owner_email,\n w.created_at,\n w.workspace_type,\n w.deleted_at,\n w.workspace_name,\n w.icon\n FROM af_workspace w\n JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id\n JOIN public.af_user u ON w.owner_uid = u.uid\n WHERE wm.uid = (\n SELECT uid FROM public.af_user WHERE uuid = $1\n );\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -25,26 +25,31 @@
|
|||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "workspace_type",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"name": "workspace_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"name": "icon",
|
||||
"type_info": "Text"
|
||||
}
|
||||
|
|
@ -58,7 +63,8 @@
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
|
|
@ -66,5 +72,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "2ebeb1af741d6866849af544be78ab44a44f9800265e49adf156b8b40b2d0f46"
|
||||
"hash": "dbebcabe81603dca27ad9fc5a5df0f1e56a62016246c5a522423102a9e9b6dae"
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n database_storage_id,\n owner_uid,\n (SELECT name FROM public.af_user WHERE uid = owner_uid) AS owner_name,\n created_at,\n workspace_type,\n deleted_at,\n workspace_name,\n icon\n FROM public.af_workspace WHERE workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n database_storage_id,\n owner_uid,\n owner_profile.name as owner_name,\n owner_profile.email as owner_email,\n af_workspace.created_at,\n workspace_type,\n af_workspace.deleted_at,\n workspace_name,\n icon\n FROM public.af_workspace\n JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid\n WHERE workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
|
|
@ -25,26 +25,31 @@
|
|||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "owner_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"ordinal": 6,
|
||||
"name": "workspace_type",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"ordinal": 7,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"ordinal": 8,
|
||||
"name": "workspace_name",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"ordinal": 9,
|
||||
"name": "icon",
|
||||
"type_info": "Text"
|
||||
}
|
||||
|
|
@ -58,7 +63,8 @@
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
|
|
@ -66,5 +72,5 @@
|
|||
false
|
||||
]
|
||||
},
|
||||
"hash": "04eb046efaa45999587db62cb32fa314f61997652c070870b44d23753ad48b5c"
|
||||
"hash": "f448ae1b28ef69f884040016072b12694e530b64a105e03a040c65b779c9d91e"
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<!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>Request to join the workspace</title>
|
||||
<style>
|
||||
.hover-opacity-90:hover {
|
||||
opacity: 0.9 !important
|
||||
}
|
||||
@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">
|
||||
Approve a user's request to join the workspace.
|
||||
 ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label="Request to join the workspace" 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%">
|
||||
<div style="width: 100%; text-align: center">
|
||||
<img src="{{ user_icon_url }}" width="48px" height="48px" alt="{{ username }}" style="max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; border-radius: 9999px; object-fit: cover">
|
||||
</div>
|
||||
<p style="width: 100%; white-space: normal; overflow-wrap: break-word; text-align: center; font-size: 24px">
|
||||
<span style="font-size: 30px; font-weight: 700">{{ username }}</span>
|
||||
<span>has requested access to </span>
|
||||
<span style="font-size: 30px; font-weight: 700;">{{ workspace_name }}</span>
|
||||
</p>
|
||||
<div role="separator" style="background-color: #cbd5e1; height: 1px; line-height: 1px; margin: 24px 20%">‍</div>
|
||||
<table align="center" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 60px">
|
||||
<div style="margin-right: 8px; height: 60px; width: 60px; overflow: hidden; border-radius: 16px; background-color: #fff; border: 2px solid black">
|
||||
<img src="{{ workspace_icon_url }}" width="100%" height="100%" alt="{{ workspace_name }}" style="max-width: 100%; vertical-align: middle; line-height: 1; overflow: hidden; object-fit: cover;">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="margin-bottom: 8px; font-weight: 700">{{ workspace_name }}</div>
|
||||
<div style="font-size: 14px; color: #64748b">
|
||||
{{ workspace_member_count }} members
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ approve_url }}" class="hover-opacity-90" style="margin-top: 32px; margin-bottom: 32px; display: inline-block; width: 60%; cursor: pointer; border-radius: 16px; padding: 16px 24px; color: #f8fafc; text-decoration: none; background-color: #9327ff; font-size: 20px; font-weight: 400; line-height: 20px">
|
||||
<!--[if mso]>
|
||||
<i style="mso-font-width: 150%; mso-text-raise: 30px" hidden> </i>
|
||||
<![endif]-->
|
||||
<span style="mso-text-raise: 16px">
|
||||
<div style="font-size: 24px; font-weight: 500">Approve request</div>
|
||||
</span>
|
||||
<!--[if mso]>
|
||||
<i hidden style="mso-font-width: 150%;"> ​</i>
|
||||
<![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
<div style="margin-left: auto; margin-right: auto; width: 70%; text-align: center; font-size: 14px; line-height: 18px; color: #64748b">
|
||||
By clicking "Approve request" above, the user will be added to the
|
||||
workspace.
|
||||
</div>
|
||||
<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>
|
||||
<p style="margin: 0; font-size: 14px; font-weight: 500; color: #000;">
|
||||
Bring projects, knowledge, and teams together with the power of AI.
|
||||
</p>
|
||||
<p style="cursor: default">
|
||||
<a href="https://twitter.com/appflowy" style="margin-right: 16px; color: #4338ca; text-decoration: none">
|
||||
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/twitter.png" width="20" alt="Maizzle" style="max-width: 100%; vertical-align: middle; line-height: 1;">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/AppFlowy" style="margin-right: 16px; color: #4338ca; text-decoration: none;">
|
||||
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/reddit.png" width="20" alt="Maizzle" style="max-width: 100%; vertical-align: middle; line-height: 1;">
|
||||
</a>
|
||||
<a href="https://github.com/AppFlowy-IO/AppFlowy" style="margin-right: 16px; color: #4338ca; text-decoration: none;">
|
||||
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/github.png" width="20" alt="Maizzle" style="max-width: 100%; vertical-align: middle; line-height: 1;">
|
||||
</a>
|
||||
<a href="https://discord.gg/9Q2xaN37tV" style="margin-right: 16px; color: #4338ca; text-decoration: none;">
|
||||
<img src="https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/images/discord.png" width="20" alt="Maizzle" style="max-width: 100%; vertical-align: middle; line-height: 1;">
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -141,4 +141,4 @@ APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
|||
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
||||
|
||||
# AppFlowy Web
|
||||
APPFLOWY_WEB_URL=
|
||||
APPFLOWY_WEB_URL=http://localhost:3000
|
||||
|
|
|
|||
2
dev.env
2
dev.env
|
|
@ -127,4 +127,4 @@ APPFLOWY_COLLABORATE_MULTI_THREAD=false
|
|||
APPFLOWY_COLLABORATE_REMOVE_BATCH_SIZE=100
|
||||
|
||||
# AppFlowy Web
|
||||
APPFLOWY_WEB_URL=
|
||||
APPFLOWY_WEB_URL=http://localhost:3000
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ services:
|
|||
- APPFLOWY_DATABASE_MAX_CONNECTIONS=20
|
||||
- APPFLOWY_AI_SERVER_HOST=${APPFLOWY_AI_SERVER_HOST}
|
||||
- APPFLOWY_AI_SERVER_PORT=${APPFLOWY_AI_SERVER_PORT}
|
||||
- APPFLOWY_WEB_URL=${APPFLOWY_WEB_URL}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
|
|
|||
|
|
@ -14,23 +14,24 @@
|
|||
module.exports = {
|
||||
build: {
|
||||
templates: {
|
||||
source: 'src/templates',
|
||||
source: "src/templates",
|
||||
destination: {
|
||||
path: 'build_local',
|
||||
path: "build_local",
|
||||
},
|
||||
assets: {
|
||||
source: 'src/images',
|
||||
destination: 'images',
|
||||
source: "src/images",
|
||||
destination: "images",
|
||||
},
|
||||
},
|
||||
},
|
||||
locals: {
|
||||
cdnBaseUrl: '',
|
||||
cdnBaseUrl: "",
|
||||
userIconUrl: "https://cdn-icons-png.flaticon.com/512/1077/1077012.png",
|
||||
userName: "John Doe",
|
||||
acceptUrl: "https://appflowy.io",
|
||||
approveUrl: "https://appflowy.io",
|
||||
workspaceName: "AppFlowy",
|
||||
workspaceMembersCount: "100",
|
||||
workspaceIconURL: "https://cdn-icons-png.flaticon.com/512/1078/1078013.png",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,15 +15,17 @@ module.exports = {
|
|||
build: {
|
||||
templates: {
|
||||
destination: {
|
||||
path: '../assets/mailer_templates/build_production',
|
||||
path: "../assets/mailer_templates/build_production",
|
||||
},
|
||||
},
|
||||
},
|
||||
locals: {
|
||||
cdnBaseUrl: 'https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/',
|
||||
cdnBaseUrl:
|
||||
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy-Cloud/main/assets/mailer_templates/build_production/",
|
||||
userIconUrl: "{{ user_icon_url }}",
|
||||
userName: "{{ username }}",
|
||||
acceptUrl: "{{ accept_url }}",
|
||||
approveUrl: "{{ approve_url }}",
|
||||
workspaceName: "{{ workspace_name }}",
|
||||
workspaceMembersCount: "{{ workspace_member_count }}",
|
||||
workspaceIconURL: "{{ workspace_icon_url }}",
|
||||
|
|
@ -32,4 +34,4 @@ module.exports = {
|
|||
removeUnusedCSS: true,
|
||||
shorthandCSS: true,
|
||||
prettify: true,
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
title: "Request to join the workspace"
|
||||
preheader: "Approve a user's request to join the workspace."
|
||||
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">
|
||||
<div class="w-full text-center">
|
||||
<img
|
||||
src="{{ userIconUrl }}"
|
||||
class="rounded-full overflow-hidden object-cover"
|
||||
width="48px"
|
||||
height="48px"
|
||||
alt="{{ userName }}"
|
||||
/>
|
||||
</div>
|
||||
<p class="w-full text-center break-words whitespace-normal text-2xl">
|
||||
<span class="text-3xl font-bold">{{ userName }}</span>
|
||||
<span class="mx-2=1">has requested access to </span>
|
||||
<span class="text-3xl font-bold">{{ workspaceName }}</span>
|
||||
</p>
|
||||
<x-divider space-x="20%" />
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td class="w-[60px]">
|
||||
<div
|
||||
style="border: 2px solid black"
|
||||
class="rounded-2xl mr-2 w-[60px] h-[60px] bg-white overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src="{{ workspaceIconURL }}"
|
||||
class="overflow-hidden object-cover"
|
||||
width="100%"
|
||||
height="100%"
|
||||
alt="{{ workspaceName }}"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-bold mb-2">{{ workspaceName }}</div>
|
||||
<div class="text-sm text-slate-500">
|
||||
{{ workspaceMembersCount }} members
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<x-button
|
||||
align="center"
|
||||
class="hover:opacity-90 cursor-pointer !text-xl !leading-[20px] !bg-[#9327ff] !font-normal w-[60%] my-8 rounded-2xl"
|
||||
href="{{ approveUrl }}"
|
||||
>
|
||||
<div class="font-medium text-[24px]">Approve request</div>
|
||||
</x-button>
|
||||
<div
|
||||
class="mx-auto leading-4.5 text-sm text-slate-500 text-center w-[70%]"
|
||||
>
|
||||
By clicking "Approve request" above, the user will be added to the
|
||||
workspace.
|
||||
</div>
|
||||
<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>
|
||||
<p class="m-0 text-sm text-black font-medium">
|
||||
Bring projects, knowledge, and teams together with the power of AI.
|
||||
</p>
|
||||
|
||||
<p class="cursor-default">
|
||||
<a
|
||||
href="https://twitter.com/appflowy"
|
||||
class="text-indigo-700 [text-decoration:none] mr-4"
|
||||
>
|
||||
<img
|
||||
src="{{ cdnBaseUrl }}images/twitter.png"
|
||||
width="20"
|
||||
alt="Maizzle"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.reddit.com/r/AppFlowy"
|
||||
class="text-indigo-700 [text-decoration:none] mr-4"
|
||||
>
|
||||
<img
|
||||
src="{{ cdnBaseUrl }}images/reddit.png"
|
||||
width="20"
|
||||
alt="Maizzle"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/AppFlowy-IO/AppFlowy"
|
||||
class="text-indigo-700 [text-decoration:none] mr-4"
|
||||
>
|
||||
<img
|
||||
src="{{ cdnBaseUrl }}images/github.png"
|
||||
width="20"
|
||||
alt="Maizzle"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/9Q2xaN37tV"
|
||||
class="text-indigo-700 [text-decoration:none] mr-4"
|
||||
>
|
||||
<img
|
||||
src="{{ cdnBaseUrl }}images/discord.png"
|
||||
width="20"
|
||||
alt="Maizzle"
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
|
|
@ -527,6 +527,7 @@ pub struct AFWorkspace {
|
|||
pub database_storage_id: Uuid,
|
||||
pub owner_uid: i64,
|
||||
pub owner_name: String,
|
||||
pub owner_email: String,
|
||||
pub workspace_type: i32,
|
||||
pub workspace_name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ pub async fn select_access_request_by_request_id<'a, E: Executor<'a, Database =
|
|||
af_workspace.database_storage_id,
|
||||
af_workspace.owner_uid,
|
||||
owner_profile.name,
|
||||
owner_profile.email,
|
||||
af_workspace.created_at,
|
||||
af_workspace.workspace_type,
|
||||
af_workspace.deleted_at,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub struct AFWorkspaceRow {
|
|||
pub database_storage_id: Option<Uuid>,
|
||||
pub owner_uid: Option<i64>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_email: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub workspace_type: i32,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
|
|
@ -46,6 +47,7 @@ impl TryFrom<AFWorkspaceRow> for AFWorkspace {
|
|||
database_storage_id,
|
||||
owner_uid,
|
||||
owner_name: value.owner_name.unwrap_or_default(),
|
||||
owner_email: value.owner_email.unwrap_or_default(),
|
||||
workspace_type: value.workspace_type,
|
||||
workspace_name,
|
||||
created_at,
|
||||
|
|
@ -61,6 +63,7 @@ pub struct AFWorkspaceWithMemberCountRow {
|
|||
pub database_storage_id: Option<Uuid>,
|
||||
pub owner_uid: Option<i64>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_email: Option<String>,
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub workspace_type: i32,
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
|
|
@ -89,6 +92,7 @@ impl TryFrom<AFWorkspaceWithMemberCountRow> for AFWorkspace {
|
|||
database_storage_id,
|
||||
owner_uid,
|
||||
owner_name: value.owner_name.unwrap_or_default(),
|
||||
owner_email: value.owner_email.unwrap_or_default(),
|
||||
workspace_type: value.workspace_type,
|
||||
workspace_name,
|
||||
created_at,
|
||||
|
|
|
|||
|
|
@ -39,19 +39,24 @@ pub async fn insert_user_workspace(
|
|||
let workspace = sqlx::query_as!(
|
||||
AFWorkspaceRow,
|
||||
r#"
|
||||
INSERT INTO public.af_workspace (owner_uid, workspace_name)
|
||||
VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2)
|
||||
RETURNING
|
||||
WITH new_workspace AS (
|
||||
INSERT INTO public.af_workspace (owner_uid, workspace_name)
|
||||
VALUES ((SELECT uid FROM public.af_user WHERE uuid = $1), $2)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT
|
||||
workspace_id,
|
||||
database_storage_id,
|
||||
owner_uid,
|
||||
(SELECT name FROM public.af_user WHERE uid = owner_uid) AS owner_name,
|
||||
created_at,
|
||||
owner_profile.name AS owner_name,
|
||||
owner_profile.email AS owner_email,
|
||||
new_workspace.created_at,
|
||||
workspace_type,
|
||||
deleted_at,
|
||||
new_workspace.deleted_at,
|
||||
workspace_name,
|
||||
icon
|
||||
;
|
||||
FROM new_workspace
|
||||
JOIN public.af_user AS owner_profile ON new_workspace.owner_uid = owner_profile.uid;
|
||||
"#,
|
||||
user_uuid,
|
||||
workspace_name,
|
||||
|
|
@ -670,13 +675,16 @@ pub async fn select_workspace<'a, E: Executor<'a, Database = Postgres>>(
|
|||
workspace_id,
|
||||
database_storage_id,
|
||||
owner_uid,
|
||||
(SELECT name FROM public.af_user WHERE uid = owner_uid) AS owner_name,
|
||||
created_at,
|
||||
owner_profile.name as owner_name,
|
||||
owner_profile.email as owner_email,
|
||||
af_workspace.created_at,
|
||||
workspace_type,
|
||||
deleted_at,
|
||||
af_workspace.deleted_at,
|
||||
workspace_name,
|
||||
icon
|
||||
FROM public.af_workspace WHERE workspace_id = $1
|
||||
FROM public.af_workspace
|
||||
JOIN public.af_user owner_profile ON af_workspace.owner_uid = owner_profile.uid
|
||||
WHERE workspace_id = $1
|
||||
"#,
|
||||
workspace_id
|
||||
)
|
||||
|
|
@ -718,7 +726,8 @@ pub async fn select_all_user_workspaces<'a, E: Executor<'a, Database = Postgres>
|
|||
w.workspace_id,
|
||||
w.database_storage_id,
|
||||
w.owner_uid,
|
||||
(SELECT name FROM public.af_user WHERE uid = w.owner_uid) AS owner_name,
|
||||
u.name AS owner_name,
|
||||
u.email AS owner_email,
|
||||
w.created_at,
|
||||
w.workspace_type,
|
||||
w.deleted_at,
|
||||
|
|
@ -726,6 +735,7 @@ pub async fn select_all_user_workspaces<'a, E: Executor<'a, Database = Postgres>
|
|||
w.icon
|
||||
FROM af_workspace w
|
||||
JOIN af_workspace_member wm ON w.workspace_id = wm.workspace_id
|
||||
JOIN public.af_user u ON w.owner_uid = u.uid
|
||||
WHERE wm.uid = (
|
||||
SELECT uid FROM public.af_user WHERE uuid = $1
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ use actix_web::{
|
|||
web::{self, Data, Json},
|
||||
Result, Scope,
|
||||
};
|
||||
use anyhow::anyhow;
|
||||
use app_error::AppError;
|
||||
use authentication::jwt::UserUuid;
|
||||
use database_entity::dto::{
|
||||
AccessRequestMinimal, ApproveAccessRequestParams, CreateAccessRequestParams,
|
||||
|
|
@ -52,7 +54,22 @@ async fn post_access_request_handler(
|
|||
let uid = state.user_cache.get_user_uid(&uuid).await?;
|
||||
let workspace_id = create_access_request_params.workspace_id;
|
||||
let view_id = create_access_request_params.view_id;
|
||||
let request_id = create_access_request(&state.pg_pool, workspace_id, view_id, uid).await?;
|
||||
let appflowy_web_url = state
|
||||
.config
|
||||
.appflowy_web_url
|
||||
.clone()
|
||||
.ok_or(AppError::Internal(anyhow!(
|
||||
"AppFlowy web url has not been set"
|
||||
)))?;
|
||||
let request_id = create_access_request(
|
||||
&state.pg_pool,
|
||||
state.mailer.clone(),
|
||||
&appflowy_web_url,
|
||||
workspace_id,
|
||||
view_id,
|
||||
uid,
|
||||
)
|
||||
.await?;
|
||||
let access_request = AccessRequestMinimal {
|
||||
request_id,
|
||||
workspace_id,
|
||||
|
|
|
|||
|
|
@ -16,18 +16,56 @@ 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,
|
||||
use crate::{
|
||||
biz::collab::{
|
||||
folder_view::{to_dto_view_icon, to_view_layout},
|
||||
ops::get_latest_collab_folder,
|
||||
},
|
||||
mailer::{Mailer, WorkspaceAccessRequestMailerParam},
|
||||
};
|
||||
|
||||
pub async fn create_access_request(
|
||||
pg_pool: &PgPool,
|
||||
mailer: Mailer,
|
||||
appflowy_web_url: &str,
|
||||
workspace_id: Uuid,
|
||||
view_id: Uuid,
|
||||
uid: i64,
|
||||
) -> Result<Uuid, AppError> {
|
||||
let request_id = insert_new_access_request(pg_pool, workspace_id, view_id, uid).await?;
|
||||
let access_request = select_access_request_by_request_id(pg_pool, request_id).await?;
|
||||
let cloned_mailer = mailer.clone();
|
||||
let approve_url = format!(
|
||||
"{}/app/approve-request?request_id={}",
|
||||
appflowy_web_url, request_id
|
||||
);
|
||||
let email = access_request.workspace.owner_email.clone();
|
||||
let recipient_name = access_request.workspace.owner_name.clone();
|
||||
// use default icon until we have workspace icon
|
||||
let workspace_icon_url =
|
||||
"https://miro.medium.com/v2/resize:fit:2400/1*mTPfm7CwU31-tLhtLNkyJw.png".to_string();
|
||||
let user_icon_url =
|
||||
"https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png"
|
||||
.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = cloned_mailer
|
||||
.send_workspace_access_request(
|
||||
&recipient_name,
|
||||
&email,
|
||||
WorkspaceAccessRequestMailerParam {
|
||||
user_icon_url,
|
||||
username: access_request.requester.name,
|
||||
workspace_name: access_request.workspace.workspace_name,
|
||||
workspace_icon_url,
|
||||
workspace_member_count: access_request.workspace.member_count.unwrap_or(0),
|
||||
approve_url: approve_url.to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send access request email: {:?}", err);
|
||||
};
|
||||
});
|
||||
Ok(request_id)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ pub async fn invite_workspace_members(
|
|||
tokio::spawn(async move {
|
||||
if let Err(err) = cloned_mailer
|
||||
.send_workspace_invite(
|
||||
invitation.email,
|
||||
&invitation.email,
|
||||
WorkspaceInviteMailerParam {
|
||||
user_icon_url,
|
||||
username: inviter_name,
|
||||
|
|
|
|||
100
src/mailer.rs
100
src/mailer.rs
|
|
@ -4,6 +4,7 @@ use lettre::transport::smtp::authentication::Credentials;
|
|||
use lettre::Address;
|
||||
use lettre::AsyncSmtpTransport;
|
||||
use lettre::AsyncTransport;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
|
|
@ -18,6 +19,9 @@ pub struct Mailer {
|
|||
smtp_username: String,
|
||||
}
|
||||
|
||||
pub const WORKSPACE_INVITE_TEMPLATE_NAME: &str = "workspace_invite";
|
||||
pub const WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME: &str = "workspace_access_request";
|
||||
|
||||
impl Mailer {
|
||||
pub async fn new(
|
||||
smtp_username: String,
|
||||
|
|
@ -33,12 +37,25 @@ impl Mailer {
|
|||
|
||||
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 template_strings = HashMap::from([
|
||||
(WORKSPACE_INVITE_TEMPLATE_NAME, workspace_invite_template),
|
||||
(
|
||||
WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,
|
||||
access_request_template,
|
||||
),
|
||||
]);
|
||||
|
||||
HANDLEBARS
|
||||
.write()
|
||||
.map_err(|err| anyhow::anyhow!(format!("Failed to write handlebars: {}", err)))?
|
||||
.register_template_string("workspace_invite", workspace_invite_template)
|
||||
.map_err(|err| anyhow::anyhow!(format!("Failed to register handlebars template: {}", err)))?;
|
||||
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,
|
||||
|
|
@ -46,13 +63,19 @@ impl Mailer {
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn send_workspace_invite(
|
||||
async fn send_email_template<T>(
|
||||
&self,
|
||||
email: String,
|
||||
param: WorkspaceInviteMailerParam,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
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("workspace_invite", ¶m)?,
|
||||
Ok(registory) => registory.render(template_name, ¶m)?,
|
||||
Err(err) => anyhow::bail!(format!("Failed to render handlebars template: {}", err)),
|
||||
};
|
||||
|
||||
|
|
@ -62,19 +85,56 @@ impl Mailer {
|
|||
self.smtp_username.parse::<Address>()?,
|
||||
))
|
||||
.to(lettre::message::Mailbox::new(
|
||||
Some(param.username.clone()),
|
||||
recipient_name,
|
||||
email.parse()?,
|
||||
))
|
||||
.subject(format!(
|
||||
"Action required: {} invited you to {} in AppFlowy",
|
||||
param.username, param.workspace_name
|
||||
))
|
||||
.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,
|
||||
param: WorkspaceInviteMailerParam,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let subject = format!(
|
||||
"Action required: {} invited you to {} in AppFlowy",
|
||||
param.username, param.workspace_name
|
||||
);
|
||||
self
|
||||
.send_email_template(
|
||||
Some(param.username.clone()),
|
||||
email,
|
||||
WORKSPACE_INVITE_TEMPLATE_NAME,
|
||||
param,
|
||||
&subject,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_workspace_access_request(
|
||||
&self,
|
||||
recipient_name: &str,
|
||||
email: &str,
|
||||
param: WorkspaceAccessRequestMailerParam,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let subject = format!(
|
||||
"Action required: {} requested access to {} in AppFlowy",
|
||||
param.username, param.workspace_name
|
||||
);
|
||||
self
|
||||
.send_email_template(
|
||||
Some(recipient_name.to_string()),
|
||||
email,
|
||||
WORKSPACE_ACCESS_REQUEST_TEMPLATE_NAME,
|
||||
param,
|
||||
&subject,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
|
|
@ -86,3 +146,13 @@ pub struct WorkspaceInviteMailerParam {
|
|||
pub workspace_member_count: String,
|
||||
pub accept_url: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct WorkspaceAccessRequestMailerParam {
|
||||
pub user_icon_url: String,
|
||||
pub username: String,
|
||||
pub workspace_name: String,
|
||||
pub workspace_icon_url: String,
|
||||
pub workspace_member_count: i64,
|
||||
pub approve_url: String,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue