Send out email after registration denied, email confirmed (fixes #5547) (#5553)

* Send out email after registration denied, email confirmed (fixes #5547)

* wip

* wip2

* all compiling

* cleanup

* move to subfolders

* update

* more fixes

* clippy

* remove line
This commit is contained in:
Nutomic 2025-04-01 08:24:05 +00:00 committed by GitHub
parent e7ddb96659
commit 79f79a4e9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 506 additions and 342 deletions

2
.gitmodules vendored
View File

@ -1,4 +1,4 @@
[submodule "crates/utils/translations"]
path = crates/utils/translations
path = crates/email/translations
url = https://github.com/LemmyNet/lemmy-translations.git
branch = main

24
Cargo.lock generated
View File

@ -3119,6 +3119,7 @@ dependencies = [
"lemmy_api_crud",
"lemmy_db_schema",
"lemmy_db_views",
"lemmy_email",
"lemmy_utils",
"pretty_assertions",
"regex",
@ -3148,6 +3149,7 @@ dependencies = [
"jsonwebtoken",
"lemmy_db_schema",
"lemmy_db_views",
"lemmy_email",
"lemmy_utils",
"mime",
"mime_guess",
@ -3166,7 +3168,6 @@ dependencies = [
"ts-rs",
"url",
"urlencoding",
"uuid",
"webmention",
"webpage",
]
@ -3186,6 +3187,7 @@ dependencies = [
"lemmy_api_common",
"lemmy_db_schema",
"lemmy_db_views",
"lemmy_email",
"lemmy_utils",
"regex",
"serde",
@ -3309,6 +3311,21 @@ dependencies = [
"url",
]
[[package]]
name = "lemmy_email"
version = "1.0.0-alpha.4"
dependencies = [
"html2text",
"lemmy_db_schema",
"lemmy_db_views",
"lemmy_utils",
"lettre",
"rosetta-build",
"rosetta-i18n",
"tracing",
"uuid",
]
[[package]]
name = "lemmy_federate"
version = "1.0.0-alpha.4"
@ -3411,10 +3428,8 @@ dependencies = [
"enum-map",
"futures",
"git-version",
"html2text",
"http 1.2.0",
"itertools 0.14.0",
"lettre",
"markdown-it",
"markdown-it-block-spoiler",
"markdown-it-footnote",
@ -3425,8 +3440,6 @@ dependencies = [
"pretty_assertions",
"regex",
"reqwest-middleware",
"rosetta-build",
"rosetta-i18n",
"serde",
"serde_json",
"smart-default",
@ -3437,7 +3450,6 @@ dependencies = [
"unicode-segmentation",
"url",
"urlencoding",
"uuid",
]
[[package]]

View File

@ -50,6 +50,7 @@ members = [
"crates/db_views",
"crates/routes",
"crates/federate",
"crates/email",
]
[workspace.lints.clippy]
@ -88,6 +89,7 @@ lemmy_api_common = { version = "=1.0.0-alpha.4", path = "./crates/api_common" }
lemmy_routes = { version = "=1.0.0-alpha.4", path = "./crates/routes" }
lemmy_db_views = { version = "=1.0.0-alpha.4", path = "./crates/db_views" }
lemmy_federate = { version = "=1.0.0-alpha.4", path = "./crates/federate" }
lemmy_email = { version = "=1.0.0-alpha.4", path = "./crates/email" }
activitypub_federation = { version = "0.6.3", default-features = false, features = [
"actix-web",
] }

View File

@ -22,6 +22,7 @@ lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true, features = ["full"] }
lemmy_db_views = { workspace = true, features = ["full"] }
lemmy_api_common = { workspace = true, features = ["full"] }
lemmy_email = { workspace = true }
activitypub_federation = { workspace = true }
bcrypt = { workspace = true }
actix-web = { workspace = true }

View File

@ -1,11 +1,7 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::ResendVerificationEmail,
utils::send_verification_email_if_required,
SuccessResponse,
};
use lemmy_api_common::{context::LemmyContext, person::ResendVerificationEmail, SuccessResponse};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_verification_email_if_required;
use lemmy_utils::error::LemmyResult;
pub async fn resend_verification_email(
@ -19,10 +15,10 @@ pub async fn resend_verification_email(
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email).await?;
send_verification_email_if_required(
&context,
&site_view.local_site,
&local_user_view.local_user,
&local_user_view.person,
&local_user_view,
&mut context.pool(),
context.settings(),
)
.await?;

View File

@ -2,10 +2,11 @@ use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::PasswordReset,
utils::{check_email_verified, send_password_reset_email},
utils::check_email_verified,
SuccessResponse,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_password_reset_email;
use lemmy_utils::error::LemmyResult;
use tracing::error;

View File

@ -3,7 +3,7 @@ use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
person::SaveUserSettings,
utils::{get_url_blocklist, process_markdown_opt, send_verification_email, slur_regex},
utils::{get_url_blocklist, process_markdown_opt, slur_regex},
SuccessResponse,
};
use lemmy_db_schema::{
@ -16,6 +16,7 @@ use lemmy_db_schema::{
utils::{diesel_opt_number_update, diesel_string_update},
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_verification_email;
use lemmy_utils::{
error::{LemmyErrorType, LemmyResult},
utils::validation::{is_valid_bio_field, is_valid_display_name, is_valid_matrix_id},
@ -49,8 +50,7 @@ pub async fn save_user_settings(
LocalUser::check_is_email_taken(&mut context.pool(), email).await?;
send_verification_email(
&site_view.local_site,
&local_user_view.local_user,
&local_user_view.person,
&local_user_view,
email,
&mut context.pool(),
context.settings(),

View File

@ -1,15 +1,11 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::VerifyEmail,
utils::send_new_applicant_email_to_admins,
SuccessResponse,
};
use lemmy_api_common::{context::LemmyContext, person::VerifyEmail, SuccessResponse};
use lemmy_db_schema::source::{
email_verification::EmailVerification,
local_user::{LocalUser, LocalUserUpdateForm},
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::{account::send_email_verified_email, admin::send_new_applicant_email_to_admins};
use lemmy_utils::error::LemmyResult;
pub async fn verify_email(
@ -48,5 +44,7 @@ pub async fn verify_email(
.await?;
}
send_email_verified_email(&local_user_view, context.settings()).await?;
Ok(Json(SuccessResponse::default()))
}

View File

@ -5,18 +5,14 @@ use lemmy_api_common::{
context::LemmyContext,
reports::comment::{CommentReportResponse, CreateCommentReport},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_comment_deleted_or_removed,
check_community_user_action,
send_new_report_email_to_admins,
slur_regex,
},
utils::{check_comment_deleted_or_removed, check_community_user_action, slur_regex},
};
use lemmy_db_schema::{
source::comment_report::{CommentReport, CommentReportForm},
traits::Reportable,
};
use lemmy_db_views::structs::{CommentReportView, CommentView, LocalUserView, SiteView};
use lemmy_email::admin::send_new_report_email_to_admins;
use lemmy_utils::error::LemmyResult;
/// Creates a comment report and notifies the moderators of the community

View File

@ -3,7 +3,7 @@ use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
reports::community::{CommunityReportResponse, CreateCommunityReport},
utils::{send_new_report_email_to_admins, slur_regex},
utils::slur_regex,
};
use lemmy_db_schema::{
source::{
@ -13,6 +13,7 @@ use lemmy_db_schema::{
traits::{Crud, Reportable},
};
use lemmy_db_views::structs::{CommunityReportView, LocalUserView, SiteView};
use lemmy_email::admin::send_new_report_email_to_admins;
use lemmy_utils::error::LemmyResult;
pub async fn create_community_report(

View File

@ -5,18 +5,14 @@ use lemmy_api_common::{
context::LemmyContext,
reports::post::{CreatePostReport, PostReportResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_community_user_action,
check_post_deleted_or_removed,
send_new_report_email_to_admins,
slur_regex,
},
utils::{check_community_user_action, check_post_deleted_or_removed, slur_regex},
};
use lemmy_db_schema::{
source::post_report::{PostReport, PostReportForm},
traits::Reportable,
};
use lemmy_db_views::structs::{LocalUserView, PostReportView, PostView, SiteView};
use lemmy_email::admin::send_new_report_email_to_admins;
use lemmy_utils::error::LemmyResult;
/// Creates a post report and notifies the moderators of the community

View File

@ -3,7 +3,7 @@ use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
reports::private_message::{CreatePrivateMessageReport, PrivateMessageReportResponse},
utils::{send_new_report_email_to_admins, slur_regex},
utils::slur_regex,
};
use lemmy_db_schema::{
source::{
@ -13,6 +13,7 @@ use lemmy_db_schema::{
traits::{Crud, Reportable},
};
use lemmy_db_views::structs::{LocalUserView, PrivateMessageReportView, SiteView};
use lemmy_email::admin::send_new_report_email_to_admins;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
pub async fn create_pm_report(

View File

@ -4,7 +4,7 @@ use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
use lemmy_api_common::{
context::LemmyContext,
site::{ApproveRegistrationApplication, RegistrationApplicationResponse},
utils::{is_admin, send_application_approved_email},
utils::is_admin,
};
use lemmy_db_schema::{
source::{
@ -15,6 +15,7 @@ use lemmy_db_schema::{
utils::{diesel_string_update, get_conn},
};
use lemmy_db_views::structs::{LocalUserView, RegistrationApplicationView};
use lemmy_email::account::{send_application_approved_email, send_application_denied_email};
use lemmy_utils::error::{LemmyError, LemmyResult};
pub async fn approve_registration_application(
@ -58,14 +59,20 @@ pub async fn approve_registration_application(
})
.await?;
if data.approve {
let approved_local_user_view =
LocalUserView::read(&mut context.pool(), approved_user_id).await?;
if approved_local_user_view.local_user.email.is_some() {
// Email sending may fail, but this won't revert the application approval
let approved_local_user_view = LocalUserView::read(&mut context.pool(), approved_user_id).await?;
if approved_local_user_view.local_user.email.is_some() {
// Email sending may fail, but this won't revert the application approval
if data.approve {
send_application_approved_email(&approved_local_user_view, context.settings()).await?;
} else {
send_application_denied_email(
&approved_local_user_view,
data.deny_reason.clone(),
context.settings(),
)
.await?;
}
};
}
// Read the view
let registration_application =

View File

@ -299,7 +299,7 @@ async fn test_application_approval() -> LemmyResult<()> {
expected_total_applications,
);
approve_registration_application(
let deny = approve_registration_application(
Json(ApproveRegistrationApplication {
id: app_with_email_2.id,
approve: false,
@ -308,7 +308,8 @@ async fn test_application_approval() -> LemmyResult<()> {
context.reset_request_count(),
admin_local_user_view.clone(),
)
.await?;
.await;
assert!(deny.is_err_and(|e| e.error_type == LemmyErrorType::NoEmailSetup));
expected_unread_applications -= 1;

View File

@ -21,13 +21,13 @@ full = [
"tracing",
"lemmy_db_views/full",
"lemmy_utils/full",
"lemmy_email",
"activitypub_federation",
"encoding_rs",
"reqwest-middleware",
"webpage",
"ts-rs",
"tokio",
"uuid",
"reqwest",
"actix-web",
"futures",
@ -46,6 +46,7 @@ full = [
lemmy_db_views = { workspace = true }
lemmy_db_schema = { workspace = true }
lemmy_utils = { workspace = true }
lemmy_email = { workspace = true, optional = true }
activitypub_federation = { workspace = true, optional = true }
serde = { workspace = true }
serde_with = { workspace = true }
@ -55,7 +56,6 @@ tracing = { workspace = true, optional = true }
reqwest-middleware = { workspace = true, optional = true }
regex = { workspace = true }
futures = { workspace = true, optional = true }
uuid = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
ts-rs = { workspace = true, optional = true }

View File

@ -3,7 +3,7 @@ use crate::{
community::CommunityResponse,
context::LemmyContext,
post::PostResponse,
utils::{check_person_instance_community_block, is_mod_or_admin, send_email_to_user},
utils::{check_person_instance_community_block, is_mod_or_admin},
};
use actix_web::web::Json;
use lemmy_db_schema::{
@ -21,10 +21,12 @@ use lemmy_db_schema::{
traits::Crud,
};
use lemmy_db_views::structs::{CommentView, CommunityView, LocalUserView, PostView};
use lemmy_utils::{
error::LemmyResult,
utils::{markdown::markdown_to_html, mention::MentionData},
use lemmy_email::notifications::{
send_comment_reply_email,
send_mention_email,
send_post_reply_email,
};
use lemmy_utils::{error::LemmyResult, utils::mention::MentionData};
pub async fn build_comment_response(
context: &LemmyContext,
@ -135,8 +137,6 @@ pub async fn send_local_notifs(
}
};
let inbox_link = format!("{}/inbox", context.settings().get_protocol_and_hostname());
// Send the local mentions
for mention in mentions
.iter()
@ -187,15 +187,14 @@ pub async fn send_local_notifs(
// Send an email to those local users that have notifications on
if do_send_email {
let lang = &mention_user_view.local_user.interface_i18n_language();
let content = markdown_to_html(&comment_content_or_post_body);
send_email_to_user(
send_mention_email(
&mention_user_view,
&lang.notification_mentioned_by_subject(&person.name),
&lang.notification_mentioned_by_body(&link, &content, &inbox_link, &person.name),
&comment_content_or_post_body,
person,
link,
context.settings(),
)
.await
.await;
}
}
}
@ -240,22 +239,15 @@ pub async fn send_local_notifs(
.ok();
if do_send_email {
let lang = &parent_user_view.local_user.interface_i18n_language();
let content = markdown_to_html(&comment.content);
send_email_to_user(
send_comment_reply_email(
&parent_user_view,
&lang.notification_comment_reply_subject(&person.name),
&lang.notification_comment_reply_body(
comment.local_url(context.settings())?,
&content,
&inbox_link,
&parent_comment.content,
&post.name,
&person.name,
),
comment,
person,
&parent_comment,
&post,
context.settings(),
)
.await
.await?;
}
}
}
@ -293,21 +285,14 @@ pub async fn send_local_notifs(
.ok();
if do_send_email {
let lang = &parent_user_view.local_user.interface_i18n_language();
let content = markdown_to_html(&comment.content);
send_email_to_user(
send_post_reply_email(
&parent_user_view,
&lang.notification_post_reply_subject(&person.name),
&lang.notification_post_reply_body(
comment.local_url(context.settings())?,
&content,
&inbox_link,
&post.name,
&person.name,
),
comment,
person,
&post,
context.settings(),
)
.await
.await?;
}
}
}

View File

@ -17,13 +17,11 @@ use lemmy_db_schema::{
source::{
comment::{Comment, CommentActions, CommentUpdateForm},
community::{Community, CommunityActions, CommunityUpdateForm},
email_verification::{EmailVerification, EmailVerificationForm},
images::{ImageDetails, RemoteImage},
instance::{Instance, InstanceActions},
local_site::LocalSite,
local_site_rate_limit::LocalSiteRateLimit,
local_site_url_blocklist::LocalSiteUrlBlocklist,
local_user::LocalUser,
mod_log::moderator::{
ModRemoveComment,
ModRemoveCommentForm,
@ -31,7 +29,6 @@ use lemmy_db_schema::{
ModRemovePostForm,
},
oauth_account::OAuthAccount,
password_reset_request::PasswordResetRequest,
person::{Person, PersonActions, PersonUpdateForm},
post::{Post, PostActions, PostReadCommentsForm},
private_message::PrivateMessage,
@ -57,13 +54,9 @@ use lemmy_db_views::{
},
};
use lemmy_utils::{
email::send_email,
error::{LemmyError, LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult},
rate_limit::{ActionType, BucketConfig},
settings::{
structs::{PictrsImageMode, Settings},
SETTINGS,
},
settings::{structs::PictrsImageMode, SETTINGS},
spawn_try_task,
utils::{
markdown::{image_links::markdown_rewrite_image_links, markdown_check_for_blocked_urls},
@ -76,7 +69,7 @@ use lemmy_utils::{
use moka::future::Cache;
use regex::{escape, Regex, RegexSet};
use std::sync::LazyLock;
use tracing::{warn, Instrument};
use tracing::Instrument;
use url::{ParseError, Url};
use urlencoding::encode;
use webmention::{Webmention, WebmentionError};
@ -412,121 +405,6 @@ pub fn honeypot_check(honeypot: &Option<String>) -> LemmyResult<()> {
}
}
pub async fn send_email_to_user(
local_user_view: &LocalUserView,
subject: &str,
body: &str,
settings: &Settings,
) {
if local_user_view.banned() || !local_user_view.local_user.send_notifications_to_email {
return;
}
if let Some(user_email) = &local_user_view.local_user.email {
match send_email(
subject,
user_email,
&local_user_view.person.name,
body,
settings,
)
.await
{
Ok(_o) => _o,
Err(e) => warn!("{}", e),
};
}
}
pub async fn send_password_reset_email(
user: &LocalUserView,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Generate a random token
let token = uuid::Uuid::new_v4().to_string();
let email = &user
.local_user
.email
.clone()
.ok_or(LemmyErrorType::EmailRequired)?;
let lang = &user.local_user.interface_i18n_language();
let subject = &lang.password_reset_subject(&user.person.name);
let protocol_and_hostname = settings.get_protocol_and_hostname();
let reset_link = format!("{}/password_change/{}", protocol_and_hostname, &token);
let body = &lang.password_reset_body(reset_link, &user.person.name);
send_email(subject, email, &user.person.name, body, settings).await?;
// Insert the row after successful send, to avoid using daily reset limit while
// email sending is broken.
let local_user_id = user.local_user.id;
PasswordResetRequest::create(pool, local_user_id, token.clone()).await?;
Ok(())
}
/// Send a verification email
pub async fn send_verification_email(
local_site: &LocalSite,
local_user: &LocalUser,
person: &Person,
new_email: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
let form = EmailVerificationForm {
local_user_id: local_user.id,
email: new_email.to_string(),
verification_token: uuid::Uuid::new_v4().to_string(),
};
let verify_link = format!(
"{}/verify_email/{}",
settings.get_protocol_and_hostname(),
&form.verification_token
);
EmailVerification::create(pool, &form).await?;
let lang = local_user.interface_i18n_language();
let subject = lang.verify_email_subject(&settings.hostname);
// If an application is required, use a translation that includes that warning.
let body = if local_site.registration_mode == RegistrationMode::RequireApplication {
lang.verify_email_body_with_application(&settings.hostname, &person.name, verify_link)
} else {
lang.verify_email_body(&settings.hostname, &person.name, verify_link)
};
send_email(&subject, new_email, &person.name, &body, settings).await
}
/// Returns true if email was sent.
pub async fn send_verification_email_if_required(
context: &LemmyContext,
local_site: &LocalSite,
local_user: &LocalUser,
person: &Person,
) -> LemmyResult<bool> {
let email = &local_user
.email
.clone()
.ok_or(LemmyErrorType::EmailRequired)?;
if !local_user.admin && local_site.require_email_verification && !local_user.email_verified {
send_verification_email(
local_site,
local_user,
person,
email,
&mut context.pool(),
context.settings(),
)
.await?;
Ok(true)
} else {
Ok(false)
}
}
pub fn local_site_rate_limit_to_rate_limit_config(
l: &LocalSiteRateLimit,
) -> EnumMap<ActionType, BucketConfig> {
@ -592,73 +470,6 @@ pub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult<RegexSet>
)
}
pub async fn send_application_approved_email(
user: &LocalUserView,
settings: &Settings,
) -> LemmyResult<()> {
let email = &user
.local_user
.email
.clone()
.ok_or(LemmyErrorType::EmailRequired)?;
let lang = &user.local_user.interface_i18n_language();
let subject = lang.registration_approved_subject(&user.person.ap_id);
let body = lang.registration_approved_body(&settings.hostname);
send_email(&subject, email, &user.person.name, &body, settings).await
}
/// Send a new applicant email notification to all admins
pub async fn send_new_applicant_email_to_admins(
applicant_username: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Collect the admins with emails
let admins = LocalUserView::list_admins_with_emails(pool).await?;
let applications_link = &format!(
"{}/registration_applications",
settings.get_protocol_and_hostname(),
);
for admin in &admins {
let email = &admin
.local_user
.email
.clone()
.ok_or(LemmyErrorType::EmailRequired)?;
let lang = &admin.local_user.interface_i18n_language();
let subject = lang.new_application_subject(&settings.hostname, applicant_username);
let body = lang.new_application_body(applications_link);
send_email(&subject, email, &admin.person.name, &body, settings).await?;
}
Ok(())
}
/// Send a report to all admins
pub async fn send_new_report_email_to_admins(
reporter_username: &str,
reported_username: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Collect the admins with emails
let admins = LocalUserView::list_admins_with_emails(pool).await?;
let reports_link = &format!("{}/reports", settings.get_protocol_and_hostname(),);
for admin in &admins {
if let Some(email) = &admin.local_user.email {
let lang = &admin.local_user.interface_i18n_language();
let subject =
lang.new_report_subject(&settings.hostname, reported_username, reporter_username);
let body = lang.new_report_body(reports_link);
send_email(&subject, email, &admin.person.name, &body, settings).await?;
}
}
Ok(())
}
pub fn check_nsfw_allowed(nsfw: Option<bool>, local_site: Option<&LocalSite>) -> LemmyResult<()> {
let is_nsfw = nsfw.unwrap_or_default();
let nsfw_disallowed = local_site.is_some_and(|s| s.disallow_nsfw_content);

View File

@ -17,6 +17,7 @@ lemmy_utils = { workspace = true, features = ["full"] }
lemmy_db_schema = { workspace = true, features = ["full"] }
lemmy_db_views = { workspace = true, features = ["full"] }
lemmy_api_common = { workspace = true, features = ["full"] }
lemmy_email = { workspace = true }
activitypub_federation = { workspace = true }
bcrypt = { workspace = true }
actix-web = { workspace = true }

View File

@ -5,13 +5,7 @@ use lemmy_api_common::{
plugins::{plugin_hook_after, plugin_hook_before},
private_message::{CreatePrivateMessage, PrivateMessageResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_private_messages_enabled,
get_url_blocklist,
process_markdown,
send_email_to_user,
slur_regex,
},
utils::{check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex},
};
use lemmy_db_schema::{
source::{
@ -21,9 +15,10 @@ use lemmy_db_schema::{
traits::{Blockable, Crud},
};
use lemmy_db_views::structs::{LocalUserView, PrivateMessageView};
use lemmy_email::notifications::send_private_message_email;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::{markdown::markdown_to_html, validation::is_valid_body_field},
utils::validation::is_valid_body_field,
};
pub async fn create_private_message(
@ -72,16 +67,12 @@ pub async fn create_private_message(
// Send email to the local recipient, if one exists
if view.recipient.local {
let recipient_id = data.recipient_id;
let local_recipient = LocalUserView::read_person(&mut context.pool(), recipient_id).await?;
let lang = &local_recipient.local_user.interface_i18n_language();
let inbox_link = format!("{}/inbox", context.settings().get_protocol_and_hostname());
let sender_name = &local_user_view.person.name;
let content = markdown_to_html(&content);
send_email_to_user(
let local_recipient =
LocalUserView::read_person(&mut context.pool(), data.recipient_id).await?;
send_private_message_email(
&local_user_view,
&local_recipient,
&lang.notification_private_message_subject(sender_name),
&lang.notification_private_message_body(inbox_link, &content, sender_name),
&content,
context.settings(),
)
.await;

View File

@ -13,8 +13,6 @@ use lemmy_api_common::{
generate_inbox_url,
honeypot_check,
password_length_check,
send_new_applicant_email_to_admins,
send_verification_email_if_required,
slur_regex,
},
};
@ -36,6 +34,10 @@ use lemmy_db_schema::{
RegistrationMode,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::{
account::send_verification_email_if_required,
admin::send_new_applicant_email_to_admins,
};
use lemmy_utils::{
error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::structs::Settings,
@ -131,7 +133,7 @@ pub async fn register(
let tx_data = data.clone();
let tx_local_site = local_site.clone();
let tx_settings = context.settings();
let (person, local_user) = conn
let user = conn
.transaction::<_, LemmyError, _>(|conn| {
async move {
// We have to create both a person, and local_user
@ -167,7 +169,11 @@ pub async fn register(
}
}
Ok((person, local_user))
Ok(LocalUserView {
person,
local_user,
instance_actions: None,
})
}
.scope_boxed()
})
@ -189,11 +195,16 @@ pub async fn register(
if !local_site.site_setup
|| (!require_registration_application && !local_site.require_email_verification)
{
let jwt = Claims::generate(local_user.id, req, &context).await?;
let jwt = Claims::generate(user.local_user.id, req, &context).await?;
login_response.jwt = Some(jwt);
} else {
login_response.verify_email_sent =
send_verification_email_if_required(&context, &local_site, &local_user, &person).await?;
login_response.verify_email_sent = send_verification_email_if_required(
&local_site,
&user,
&mut context.pool(),
context.settings(),
)
.await?;
if require_registration_application {
login_response.registration_created = true;
@ -348,7 +359,7 @@ pub async fn authenticate_with_oauth(
let tx_data = data.clone();
let tx_local_site = local_site.clone();
let tx_settings = context.settings();
let (person, local_user) = conn
let user = conn
.transaction::<_, LemmyError, _>(|conn| {
async move {
// make sure the username is provided
@ -412,16 +423,25 @@ pub async fn authenticate_with_oauth(
login_response.registration_created = true;
}
}
Ok((person, local_user))
Ok(LocalUserView {
person,
local_user,
instance_actions: None,
})
}
.scope_boxed()
})
.await?;
// Check email is verified when required
login_response.verify_email_sent =
send_verification_email_if_required(&context, &local_site, &local_user, &person).await?;
local_user
login_response.verify_email_sent = send_verification_email_if_required(
&local_site,
&user,
&mut context.pool(),
context.settings(),
)
.await?;
user.local_user
}
};

View File

@ -24,10 +24,7 @@ use diesel::{
QueryDsl,
};
use diesel_async::RunQueryDsl;
use lemmy_utils::{
email::{lang_str_to_lang, translations::Lang},
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
impl LocalUser {
pub async fn create(
@ -291,10 +288,6 @@ impl LocalUser {
Err(LemmyErrorType::NotHigherMod)?
}
}
pub fn interface_i18n_language(&self) -> Lang {
lang_str_to_lang(&self.interface_language)
}
}
/// Adds some helper functions for an optional LocalUser

37
crates/email/Cargo.toml Normal file
View File

@ -0,0 +1,37 @@
[package]
name = "lemmy_email"
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
[lib]
name = "lemmy_email"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
lemmy_utils = { workspace = true, features = ["full"] }
lemmy_db_schema = { workspace = true, features = ["full"] }
lemmy_db_views = { workspace = true, features = ["full"] }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
rosetta-i18n = { workspace = true }
html2text = "0.14.0"
lettre = { version = "0.11.12", default-features = false, features = [
"builder",
"smtp-transport",
"tokio1-rustls-tls",
"pool",
] }
[dev-dependencies]
[build-dependencies]
rosetta-build = { version = "0.1.3", default-features = false }

127
crates/email/src/account.rs Normal file
View File

@ -0,0 +1,127 @@
use crate::{send_email, user_email, user_language};
use lemmy_db_schema::{
source::{
email_verification::{EmailVerification, EmailVerificationForm},
local_site::LocalSite,
password_reset_request::PasswordResetRequest,
},
utils::DbPool,
RegistrationMode,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, settings::structs::Settings};
pub async fn send_password_reset_email(
user: &LocalUserView,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Generate a random token
let token = uuid::Uuid::new_v4().to_string();
let lang = user_language(user);
let subject = &lang.password_reset_subject(&user.person.name);
let protocol_and_hostname = settings.get_protocol_and_hostname();
let reset_link = format!("{}/password_change/{}", protocol_and_hostname, &token);
let email = user_email(user)?;
let body = &lang.password_reset_body(reset_link, &user.person.name);
send_email(subject, &email, &user.person.name, body, settings).await?;
// Insert the row after successful send, to avoid using daily reset limit while
// email sending is broken.
let local_user_id = user.local_user.id;
PasswordResetRequest::create(pool, local_user_id, token.clone()).await?;
Ok(())
}
/// Send a verification email
pub async fn send_verification_email(
local_site: &LocalSite,
user: &LocalUserView,
new_email: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
let form = EmailVerificationForm {
local_user_id: user.local_user.id,
email: new_email.to_string(),
verification_token: uuid::Uuid::new_v4().to_string(),
};
let verify_link = format!(
"{}/verify_email/{}",
settings.get_protocol_and_hostname(),
&form.verification_token
);
EmailVerification::create(pool, &form).await?;
let lang = user_language(user);
let subject = lang.verify_email_subject(&settings.hostname);
// If an application is required, use a translation that includes that warning.
let body = if local_site.registration_mode == RegistrationMode::RequireApplication {
lang.verify_email_body_with_application(&settings.hostname, &user.person.name, verify_link)
} else {
lang.verify_email_body(&settings.hostname, &user.person.name, verify_link)
};
send_email(&subject, new_email, &user.person.name, &body, settings).await
}
/// Returns true if email was sent.
pub async fn send_verification_email_if_required(
local_site: &LocalSite,
user: &LocalUserView,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<bool> {
if !user.local_user.admin
&& local_site.require_email_verification
&& !user.local_user.email_verified
{
let email = user_email(user)?;
send_verification_email(local_site, user, &email, pool, settings).await?;
Ok(true)
} else {
Ok(false)
}
}
pub async fn send_application_approved_email(
user: &LocalUserView,
settings: &Settings,
) -> LemmyResult<()> {
let lang = user_language(user);
let subject = lang.registration_approved_subject(&user.person.name);
let email = user_email(user)?;
let body = lang.registration_approved_body(&settings.hostname);
send_email(&subject, &email, &user.person.name, &body, settings).await?;
Ok(())
}
pub async fn send_application_denied_email(
user: &LocalUserView,
deny_reason: Option<String>,
settings: &Settings,
) -> LemmyResult<()> {
let lang = user_language(user);
let subject = lang.registration_denied_subject(&user.person.name);
let email = user_email(user)?;
let body = lang.new_registration_denied_body(
&settings.hostname,
deny_reason.unwrap_or("unknown".to_string()),
);
send_email(&subject, &email, &user.person.name, &body, settings).await?;
Ok(())
}
pub async fn send_email_verified_email(
user: &LocalUserView,
settings: &Settings,
) -> LemmyResult<()> {
let lang = user_language(user);
let subject = lang.email_verified_subject(&user.person.name);
let email = user_email(user)?;
let body = lang.email_verified_body();
send_email(&subject, &email, &user.person.name, body, settings).await?;
Ok(())
}

53
crates/email/src/admin.rs Normal file
View File

@ -0,0 +1,53 @@
use crate::{send_email, user_language};
use lemmy_db_schema::utils::DbPool;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, settings::structs::Settings};
/// Send a new applicant email notification to all admins
pub async fn send_new_applicant_email_to_admins(
applicant_username: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Collect the admins with emails
let admins = LocalUserView::list_admins_with_emails(pool).await?;
let applications_link = &format!(
"{}/registration_applications",
settings.get_protocol_and_hostname(),
);
for admin in &admins {
if let Some(email) = &admin.local_user.email {
let lang = user_language(admin);
let subject = lang.new_application_subject(&settings.hostname, applicant_username);
let body = lang.new_application_body(applications_link);
send_email(&subject, email, &admin.person.name, &body, settings).await?;
}
}
Ok(())
}
/// Send a report to all admins
pub async fn send_new_report_email_to_admins(
reporter_username: &str,
reported_username: &str,
pool: &mut DbPool<'_>,
settings: &Settings,
) -> LemmyResult<()> {
// Collect the admins with emails
let admins = LocalUserView::list_admins_with_emails(pool).await?;
let reports_link = &format!("{}/reports", settings.get_protocol_and_hostname(),);
for admin in &admins {
if let Some(email) = &admin.local_user.email {
let lang = user_language(admin);
let subject =
lang.new_report_subject(&settings.hostname, reported_username, reporter_username);
let body = lang.new_report_body(reports_link);
send_email(&subject, email, &admin.person.name, &body, settings).await?;
}
}
Ok(())
}

View File

@ -1,8 +1,9 @@
use crate::{
use lemmy_db_schema::sensitive::SensitiveString;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
settings::structs::Settings,
};
use html2text;
use lettre::{
message::{Mailbox, MultiPart},
transport::smtp::extension::ClientId,
@ -15,13 +16,20 @@ use std::{str::FromStr, sync::OnceLock};
use translations::Lang;
use uuid::Uuid;
pub mod translations {
pub mod account;
pub mod admin;
pub mod notifications;
mod translations {
rosetta_i18n::include_translations!();
}
type AsyncSmtpTransport = lettre::AsyncSmtpTransport<lettre::Tokio1Executor>;
pub async fn send_email(
fn inbox_link(settings: &Settings) -> String {
format!("{}/inbox", settings.get_protocol_and_hostname())
}
async fn send_email(
subject: &str,
to_email: &str,
to_username: &str,
@ -74,10 +82,18 @@ pub async fn send_email(
}
#[allow(clippy::expect_used)]
pub fn lang_str_to_lang(lang: &str) -> Lang {
let lang_id = LanguageId::new(lang);
fn user_language(local_user_view: &LocalUserView) -> Lang {
let lang_id = LanguageId::new(&local_user_view.local_user.interface_language);
Lang::from_language_id(&lang_id).unwrap_or_else(|| {
let en = LanguageId::new("en");
Lang::from_language_id(&en).expect("default language")
})
}
fn user_email(local_user_view: &LocalUserView) -> LemmyResult<SensitiveString> {
local_user_view
.local_user
.email
.clone()
.ok_or(LemmyErrorType::EmailRequired.into())
}

View File

@ -0,0 +1,135 @@
use crate::{inbox_link, send_email, user_language};
use lemmy_db_schema::{
newtypes::DbUrl,
source::{comment::Comment, person::Person, post::Post},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{
error::LemmyResult,
settings::structs::Settings,
utils::markdown::markdown_to_html,
};
use tracing::warn;
pub async fn send_mention_email(
mention_user_view: &LocalUserView,
content: &str,
person: &Person,
link: DbUrl,
settings: &Settings,
) {
let inbox_link = inbox_link(settings);
let lang = user_language(mention_user_view);
let content = markdown_to_html(content);
send_email_to_user(
mention_user_view,
&lang.notification_mentioned_by_subject(&person.name),
&lang.notification_mentioned_by_body(&link, &content, &inbox_link, &person.name),
settings,
)
.await
}
pub async fn send_comment_reply_email(
parent_user_view: &LocalUserView,
comment: &Comment,
person: &Person,
parent_comment: &Comment,
post: &Post,
settings: &Settings,
) -> LemmyResult<()> {
let inbox_link = inbox_link(settings);
let lang = user_language(parent_user_view);
let content = markdown_to_html(&comment.content);
send_email_to_user(
parent_user_view,
&lang.notification_comment_reply_subject(&person.name),
&lang.notification_comment_reply_body(
comment.local_url(settings)?,
&content,
&inbox_link,
&parent_comment.content,
&post.name,
&person.name,
),
settings,
)
.await;
Ok(())
}
pub async fn send_post_reply_email(
parent_user_view: &LocalUserView,
comment: &Comment,
person: &Person,
post: &Post,
settings: &Settings,
) -> LemmyResult<()> {
let inbox_link = inbox_link(settings);
let lang = user_language(parent_user_view);
let content = markdown_to_html(&comment.content);
send_email_to_user(
parent_user_view,
&lang.notification_post_reply_subject(&person.name),
&lang.notification_post_reply_body(
comment.local_url(settings)?,
&content,
&inbox_link,
&post.name,
&person.name,
),
settings,
)
.await;
Ok(())
}
pub async fn send_private_message_email(
sender: &LocalUserView,
local_recipient: &LocalUserView,
content: &str,
settings: &Settings,
) {
let inbox_link = inbox_link(settings);
let lang = user_language(local_recipient);
let sender_name = &sender.person.name;
let content = markdown_to_html(content);
send_email_to_user(
local_recipient,
&lang.notification_private_message_subject(sender_name),
&lang.notification_private_message_body(inbox_link, &content, sender_name),
settings,
)
.await;
}
async fn send_email_to_user(
local_user_view: &LocalUserView,
subject: &str,
body: &str,
settings: &Settings,
) {
let banned = local_user_view
.instance_actions
.as_ref()
.and_then(|i| i.received_ban)
.is_some();
if banned || !local_user_view.local_user.send_notifications_to_email {
return;
}
if let Some(user_email) = &local_user_view.local_user.email {
match send_email(
subject,
user_email,
&local_user_view.person.name,
body,
settings,
)
.await
{
Ok(_o) => _o,
Err(e) => warn!("{}", e),
};
}
}

@ -0,0 +1 @@
Subproject commit 56581d60250680947e3e328bab21bc9e169df22c

View File

@ -25,7 +25,6 @@ workspace = true
full = [
"ts-rs",
"diesel",
"rosetta-i18n",
"actix-web",
"reqwest-middleware",
"tracing",
@ -42,9 +41,6 @@ full = [
"enum-map",
"futures",
"tokio",
"html2text",
"lettre",
"uuid",
"itertools",
"markdown-it",
"moka",
@ -68,19 +64,10 @@ futures = { workspace = true, optional = true }
diesel = { workspace = true, optional = true, features = ["chrono"] }
http = { workspace = true, optional = true }
doku = { workspace = true, features = ["url-2"], optional = true }
uuid = { workspace = true, optional = true, features = ["v4"] }
rosetta-i18n = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }
urlencoding = { workspace = true, optional = true }
html2text = { version = "0.14.0", optional = true }
deser-hjson = { version = "2.2.4", optional = true }
smart-default = { version = "0.7.1", optional = true }
lettre = { version = "0.11.12", default-features = false, features = [
"builder",
"smtp-transport",
"tokio1-rustls-tls",
"pool",
], optional = true }
markdown-it = { version = "0.6.1", optional = true }
ts-rs = { workspace = true, optional = true }
enum-map = { version = "2.7", optional = true }
@ -97,6 +84,3 @@ unicode-segmentation = "1.9.0"
[dev-dependencies]
pretty_assertions = { workspace = true }
[build-dependencies]
rosetta-build = { version = "0.1.3", default-features = false }

View File

@ -3,7 +3,6 @@ use cfg_if::cfg_if;
cfg_if! {
if #[cfg(feature = "full")] {
pub mod cache_header;
pub mod email;
pub mod rate_limit;
pub mod request;
pub mod response;

View File

@ -166,10 +166,10 @@ pub struct EmailConfig {
/// https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
#[default("smtp://localhost:25")]
#[doku(example = "smtps://user:pass@hostname:port")]
pub(crate) connection: String,
pub connection: String,
/// Address to send emails from, eg "noreply@your-instance.com"
#[doku(example = "noreply@example.com")]
pub(crate) smtp_from_address: String,
pub smtp_from_address: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default, Document)]

@ -1 +0,0 @@
Subproject commit dcb89f4725f69c2d57070650df63622cdfb90f8d