mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2025-10-10 04:25:44 +08:00
659 lines
24 KiB
Rust
659 lines
24 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use ahash::AHashSet;
|
|
use directory::{
|
|
backend::internal::{
|
|
lookup::DirectoryStore,
|
|
manage::{ChangedPrincipals, ManageDirectory},
|
|
PrincipalField,
|
|
},
|
|
Permission, Principal, QueryBy, Type,
|
|
};
|
|
use jmap_proto::{
|
|
request::RequestMethod,
|
|
types::{acl::Acl, collection::Collection, id::Id},
|
|
};
|
|
use std::{
|
|
hash::{DefaultHasher, Hash, Hasher},
|
|
sync::Arc,
|
|
};
|
|
use store::{dispatch::lookup::KeyValue, query::acl::AclQuery};
|
|
use trc::AddContext;
|
|
use utils::map::{
|
|
bitmap::{Bitmap, BitmapItem},
|
|
vec_map::VecMap,
|
|
};
|
|
|
|
use crate::{Server, KV_TOKEN_REVISION};
|
|
|
|
use super::{roles::RolePermissions, AccessToken, ResourceToken, TenantInfo};
|
|
|
|
pub enum PrincipalOrId {
|
|
Principal(Principal),
|
|
Id(u32),
|
|
}
|
|
|
|
impl Server {
|
|
async fn build_access_token_from_principal(
|
|
&self,
|
|
mut principal: Principal,
|
|
revision: u64,
|
|
) -> trc::Result<AccessToken> {
|
|
let mut role_permissions = RolePermissions::default();
|
|
|
|
// Apply role permissions
|
|
for role_id in principal.iter_int(PrincipalField::Roles) {
|
|
role_permissions.union(self.get_role_permissions(role_id as u32).await?.as_ref());
|
|
}
|
|
|
|
// Add principal permissions
|
|
for (permissions, field) in [
|
|
(
|
|
&mut role_permissions.enabled,
|
|
PrincipalField::EnabledPermissions,
|
|
),
|
|
(
|
|
&mut role_permissions.disabled,
|
|
PrincipalField::DisabledPermissions,
|
|
),
|
|
] {
|
|
for permission in principal.iter_int(field) {
|
|
let permission = permission as usize;
|
|
if permission < Permission::COUNT {
|
|
permissions.set(permission);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply principal permissions
|
|
let mut permissions = role_permissions.finalize();
|
|
|
|
// SPDX-SnippetBegin
|
|
// SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
|
|
// SPDX-License-Identifier: LicenseRef-SEL
|
|
|
|
let mut tenant = None;
|
|
#[cfg(feature = "enterprise")]
|
|
if self.is_enterprise_edition() {
|
|
if let Some(tenant_id) = principal.get_int(PrincipalField::Tenant).map(|v| v as u32) {
|
|
// Limit tenant permissions
|
|
permissions.intersection(&self.get_role_permissions(tenant_id).await?.enabled);
|
|
|
|
// Obtain tenant quota
|
|
tenant = Some(TenantInfo {
|
|
id: tenant_id,
|
|
quota: self
|
|
.store()
|
|
.query(QueryBy::Id(tenant_id), false)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.ok_or_else(|| {
|
|
trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details("Tenant not found")
|
|
.id(tenant_id)
|
|
.caused_by(trc::location!())
|
|
})?
|
|
.get_int(PrincipalField::Quota)
|
|
.unwrap_or_default(),
|
|
});
|
|
}
|
|
}
|
|
|
|
// SPDX-SnippetEnd
|
|
|
|
// Build access token
|
|
let mut access_token = AccessToken {
|
|
primary_id: principal.id(),
|
|
member_of: principal
|
|
.iter_int(PrincipalField::MemberOf)
|
|
.map(|v| v as u32)
|
|
.collect(),
|
|
access_to: VecMap::new(),
|
|
tenant,
|
|
name: principal.take_str(PrincipalField::Name).unwrap_or_default(),
|
|
description: principal.take_str(PrincipalField::Description),
|
|
emails: principal
|
|
.take_str_array(PrincipalField::Emails)
|
|
.unwrap_or_default(),
|
|
quota: principal.quota(),
|
|
permissions,
|
|
obj_size: 0,
|
|
revision,
|
|
};
|
|
|
|
for grant_account_id in [access_token.primary_id]
|
|
.into_iter()
|
|
.chain(access_token.member_of.iter().copied())
|
|
{
|
|
for acl_item in self
|
|
.store()
|
|
.acl_query(AclQuery::HasAccess { grant_account_id })
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
{
|
|
if !access_token.is_member(acl_item.to_account_id) {
|
|
let acl = Bitmap::<Acl>::from(acl_item.permissions);
|
|
let collection = Collection::from(acl_item.to_collection);
|
|
if !collection.is_valid() {
|
|
return Err(trc::StoreEvent::DataCorruption
|
|
.ctx(trc::Key::Reason, "Corrupted collection found in ACL key.")
|
|
.details(format!("{acl_item:?}"))
|
|
.account_id(grant_account_id)
|
|
.caused_by(trc::location!()));
|
|
}
|
|
|
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
|
if acl.contains(Acl::Read) || acl.contains(Acl::Administer) {
|
|
collections.insert(collection);
|
|
}
|
|
if collection == Collection::Mailbox
|
|
&& (acl.contains(Acl::ReadItems) || acl.contains(Acl::Administer))
|
|
{
|
|
collections.insert(Collection::Email);
|
|
}
|
|
|
|
if !collections.is_empty() {
|
|
access_token
|
|
.access_to
|
|
.get_mut_or_insert_with(acl_item.to_account_id, Bitmap::new)
|
|
.union(&collections);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(access_token.update_size())
|
|
}
|
|
|
|
async fn build_access_token(&self, account_id: u32, revision: u64) -> trc::Result<AccessToken> {
|
|
let err = match self.directory().query(QueryBy::Id(account_id), true).await {
|
|
Ok(Some(principal)) => {
|
|
return self
|
|
.build_access_token_from_principal(principal, revision)
|
|
.await
|
|
}
|
|
Ok(None) => Err(trc::AuthEvent::Error
|
|
.into_err()
|
|
.details("Account not found.")
|
|
.caused_by(trc::location!())),
|
|
Err(err) => Err(err),
|
|
};
|
|
|
|
match &self.core.jmap.fallback_admin {
|
|
Some((_, secret)) if account_id == u32::MAX => {
|
|
self.build_access_token_from_principal(Principal::fallback_admin(secret), revision)
|
|
.await
|
|
}
|
|
_ => err,
|
|
}
|
|
}
|
|
|
|
pub async fn get_access_token(
|
|
&self,
|
|
principal: impl Into<PrincipalOrId>,
|
|
) -> trc::Result<Arc<AccessToken>> {
|
|
let principal = principal.into();
|
|
|
|
// Obtain current revision
|
|
let principal_id = principal.id();
|
|
let revision = self.fetch_token_revision(principal_id).await;
|
|
|
|
match self
|
|
.inner
|
|
.cache
|
|
.access_tokens
|
|
.get_value_or_guard_async(&principal_id)
|
|
.await
|
|
{
|
|
Ok(token) => {
|
|
if revision == Some(token.revision) {
|
|
Ok(token)
|
|
} else {
|
|
let revision = revision.unwrap_or(u64::MAX);
|
|
let token: Arc<AccessToken> = match principal {
|
|
PrincipalOrId::Principal(principal) => {
|
|
self.build_access_token_from_principal(principal, revision)
|
|
.await?
|
|
}
|
|
PrincipalOrId::Id(account_id) => {
|
|
self.build_access_token(account_id, revision).await?
|
|
}
|
|
}
|
|
.into();
|
|
|
|
self.inner
|
|
.cache
|
|
.access_tokens
|
|
.insert(token.primary_id(), token.clone());
|
|
|
|
Ok(token)
|
|
}
|
|
}
|
|
Err(guard) => {
|
|
let revision = revision.unwrap_or(u64::MAX);
|
|
let token: Arc<AccessToken> = match principal {
|
|
PrincipalOrId::Principal(principal) => {
|
|
self.build_access_token_from_principal(principal, revision)
|
|
.await?
|
|
}
|
|
PrincipalOrId::Id(account_id) => {
|
|
self.build_access_token(account_id, revision).await?
|
|
}
|
|
}
|
|
.into();
|
|
let _ = guard.insert(token.clone());
|
|
Ok(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn increment_token_revision(&self, changed_principals: ChangedPrincipals) {
|
|
let mut nested_principals = Vec::new();
|
|
|
|
for (id, changed_principal) in changed_principals.iter() {
|
|
self.increment_revision(*id).await;
|
|
|
|
if changed_principal.member_change {
|
|
if changed_principal.typ == Type::Tenant {
|
|
match self
|
|
.store()
|
|
.list_principals(
|
|
None,
|
|
(*id).into(),
|
|
&[Type::Individual, Type::Group, Type::Role, Type::ApiKey],
|
|
&[PrincipalField::Name],
|
|
0,
|
|
0,
|
|
)
|
|
.await
|
|
{
|
|
Ok(principals) => {
|
|
for principal in principals.items {
|
|
if !changed_principals.contains(principal.id()) {
|
|
self.increment_revision(principal.id()).await;
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
trc::error!(err
|
|
.details("Failed to list principals")
|
|
.caused_by(trc::location!())
|
|
.account_id(*id));
|
|
}
|
|
}
|
|
} else {
|
|
nested_principals.push(*id);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !nested_principals.is_empty() {
|
|
let mut fetched_ids = AHashSet::new();
|
|
let mut ids = nested_principals.into_iter();
|
|
let mut ids_stack = vec![];
|
|
|
|
loop {
|
|
if let Some(id) = ids.next() {
|
|
// Skip if already fetched
|
|
if !fetched_ids.insert(id) {
|
|
continue;
|
|
}
|
|
|
|
// Increment revision
|
|
if !changed_principals.contains(id) {
|
|
self.increment_revision(id).await;
|
|
}
|
|
|
|
// Obtain principal
|
|
match self.store().get_members(id).await {
|
|
Ok(members) => {
|
|
ids_stack.push(ids);
|
|
ids = members.into_iter();
|
|
}
|
|
Err(err) => {
|
|
trc::error!(err
|
|
.details("Failed to obtain principal")
|
|
.caused_by(trc::location!())
|
|
.account_id(id));
|
|
}
|
|
}
|
|
} else if let Some(prev_ids) = ids_stack.pop() {
|
|
ids = prev_ids;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn increment_revision(&self, id: u32) {
|
|
if let Err(err) = self
|
|
.in_memory_store()
|
|
.counter_incr(
|
|
KeyValue::with_prefix(KV_TOKEN_REVISION, id.to_be_bytes(), 1).expires(30 * 86400),
|
|
false,
|
|
)
|
|
.await
|
|
{
|
|
trc::error!(err
|
|
.details("Failed to increment principal revision")
|
|
.account_id(id));
|
|
}
|
|
}
|
|
|
|
pub async fn fetch_token_revision(&self, id: u32) -> Option<u64> {
|
|
match self
|
|
.in_memory_store()
|
|
.counter_get(KeyValue::<()>::build_key(
|
|
KV_TOKEN_REVISION,
|
|
id.to_be_bytes(),
|
|
))
|
|
.await
|
|
{
|
|
Ok(revision) => (revision as u64).into(),
|
|
Err(err) => {
|
|
trc::error!(err
|
|
.details("Failed to obtain principal revision")
|
|
.account_id(id));
|
|
None
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<u32> for PrincipalOrId {
|
|
fn from(id: u32) -> Self {
|
|
Self::Id(id)
|
|
}
|
|
}
|
|
|
|
impl From<Principal> for PrincipalOrId {
|
|
fn from(principal: Principal) -> Self {
|
|
Self::Principal(principal)
|
|
}
|
|
}
|
|
|
|
impl PrincipalOrId {
|
|
pub fn id(&self) -> u32 {
|
|
match self {
|
|
Self::Principal(principal) => principal.id(),
|
|
Self::Id(id) => *id,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AccessToken {
|
|
pub fn from_id(primary_id: u32) -> Self {
|
|
Self {
|
|
primary_id,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn with_access_to(self, access_to: VecMap<u32, Bitmap<Collection>>) -> Self {
|
|
Self { access_to, ..self }
|
|
}
|
|
|
|
pub fn with_permission(mut self, permission: Permission) -> Self {
|
|
self.permissions.set(permission.id());
|
|
self
|
|
}
|
|
|
|
pub fn state(&self) -> u32 {
|
|
// Hash state
|
|
let mut s = DefaultHasher::new();
|
|
self.member_of.hash(&mut s);
|
|
self.access_to.hash(&mut s);
|
|
s.finish() as u32
|
|
}
|
|
|
|
pub fn primary_id(&self) -> u32 {
|
|
self.primary_id
|
|
}
|
|
|
|
pub fn secondary_ids(&self) -> impl Iterator<Item = &u32> {
|
|
self.member_of
|
|
.iter()
|
|
.chain(self.access_to.iter().map(|(id, _)| id))
|
|
}
|
|
|
|
pub fn is_member(&self, account_id: u32) -> bool {
|
|
self.primary_id == account_id
|
|
|| self.member_of.contains(&account_id)
|
|
|| self.has_permission(Permission::Impersonate)
|
|
}
|
|
|
|
pub fn is_primary_id(&self, account_id: u32) -> bool {
|
|
self.primary_id == account_id
|
|
}
|
|
|
|
#[inline(always)]
|
|
pub fn has_permission(&self, permission: Permission) -> bool {
|
|
self.permissions.get(permission.id())
|
|
}
|
|
|
|
pub fn assert_has_permission(&self, permission: Permission) -> trc::Result<()> {
|
|
if self.has_permission(permission) {
|
|
Ok(())
|
|
} else {
|
|
Err(trc::SecurityEvent::Unauthorized
|
|
.into_err()
|
|
.details(permission.name()))
|
|
}
|
|
}
|
|
|
|
pub fn permissions(&self) -> Vec<Permission> {
|
|
const USIZE_BITS: usize = std::mem::size_of::<usize>() * 8;
|
|
const USIZE_MASK: u32 = USIZE_BITS as u32 - 1;
|
|
let mut permissions = Vec::new();
|
|
|
|
for (block_num, bytes) in self.permissions.inner().iter().enumerate() {
|
|
let mut bytes = *bytes;
|
|
|
|
while bytes != 0 {
|
|
let item = USIZE_MASK - bytes.leading_zeros();
|
|
bytes ^= 1 << item;
|
|
if let Some(permission) =
|
|
Permission::from_id((block_num * USIZE_BITS) + item as usize)
|
|
{
|
|
permissions.push(permission);
|
|
}
|
|
}
|
|
}
|
|
permissions
|
|
}
|
|
|
|
pub fn is_shared(&self, account_id: u32) -> bool {
|
|
!self.is_member(account_id) && self.access_to.iter().any(|(id, _)| *id == account_id)
|
|
}
|
|
|
|
pub fn shared_accounts(&self, collection: impl Into<Collection>) -> impl Iterator<Item = &u32> {
|
|
let collection = collection.into();
|
|
self.member_of
|
|
.iter()
|
|
.chain(self.access_to.iter().filter_map(move |(id, cols)| {
|
|
if cols.contains(collection) {
|
|
id.into()
|
|
} else {
|
|
None
|
|
}
|
|
}))
|
|
}
|
|
|
|
pub fn has_access(&self, to_account_id: u32, to_collection: impl Into<Collection>) -> bool {
|
|
let to_collection = to_collection.into();
|
|
self.is_member(to_account_id)
|
|
|| self.access_to.iter().any(|(id, collections)| {
|
|
*id == to_account_id && collections.contains(to_collection)
|
|
})
|
|
}
|
|
|
|
pub fn assert_has_access(
|
|
&self,
|
|
to_account_id: Id,
|
|
to_collection: Collection,
|
|
) -> trc::Result<&Self> {
|
|
if self.has_access(to_account_id.document_id(), to_collection) {
|
|
Ok(self)
|
|
} else {
|
|
Err(trc::JmapEvent::Forbidden.into_err().details(format!(
|
|
"You do not have access to account {}",
|
|
to_account_id
|
|
)))
|
|
}
|
|
}
|
|
|
|
pub fn assert_is_member(&self, account_id: Id) -> trc::Result<&Self> {
|
|
if self.is_member(account_id.document_id()) {
|
|
Ok(self)
|
|
} else {
|
|
Err(trc::JmapEvent::Forbidden
|
|
.into_err()
|
|
.details(format!("You are not an owner of account {}", account_id)))
|
|
}
|
|
}
|
|
|
|
pub fn assert_has_jmap_permission(&self, request: &RequestMethod) -> trc::Result<()> {
|
|
let permission = match request {
|
|
RequestMethod::Get(m) => match &m.arguments {
|
|
jmap_proto::method::get::RequestArguments::Email(_) => Permission::JmapEmailGet,
|
|
jmap_proto::method::get::RequestArguments::Mailbox => Permission::JmapMailboxGet,
|
|
jmap_proto::method::get::RequestArguments::Thread => Permission::JmapThreadGet,
|
|
jmap_proto::method::get::RequestArguments::Identity => Permission::JmapIdentityGet,
|
|
jmap_proto::method::get::RequestArguments::EmailSubmission => {
|
|
Permission::JmapEmailSubmissionGet
|
|
}
|
|
jmap_proto::method::get::RequestArguments::PushSubscription => {
|
|
Permission::JmapPushSubscriptionGet
|
|
}
|
|
jmap_proto::method::get::RequestArguments::SieveScript => {
|
|
Permission::JmapSieveScriptGet
|
|
}
|
|
jmap_proto::method::get::RequestArguments::VacationResponse => {
|
|
Permission::JmapVacationResponseGet
|
|
}
|
|
jmap_proto::method::get::RequestArguments::Principal => {
|
|
Permission::JmapPrincipalGet
|
|
}
|
|
jmap_proto::method::get::RequestArguments::Quota => Permission::JmapQuotaGet,
|
|
jmap_proto::method::get::RequestArguments::Blob(_) => Permission::JmapBlobGet,
|
|
},
|
|
RequestMethod::Set(m) => match &m.arguments {
|
|
jmap_proto::method::set::RequestArguments::Email => Permission::JmapEmailSet,
|
|
jmap_proto::method::set::RequestArguments::Mailbox(_) => Permission::JmapMailboxSet,
|
|
jmap_proto::method::set::RequestArguments::Identity => Permission::JmapIdentitySet,
|
|
jmap_proto::method::set::RequestArguments::EmailSubmission(_) => {
|
|
Permission::JmapEmailSubmissionSet
|
|
}
|
|
jmap_proto::method::set::RequestArguments::PushSubscription => {
|
|
Permission::JmapPushSubscriptionSet
|
|
}
|
|
jmap_proto::method::set::RequestArguments::SieveScript(_) => {
|
|
Permission::JmapSieveScriptSet
|
|
}
|
|
jmap_proto::method::set::RequestArguments::VacationResponse => {
|
|
Permission::JmapVacationResponseSet
|
|
}
|
|
},
|
|
RequestMethod::Changes(m) => match m.arguments {
|
|
jmap_proto::method::changes::RequestArguments::Email => {
|
|
Permission::JmapEmailChanges
|
|
}
|
|
jmap_proto::method::changes::RequestArguments::Mailbox => {
|
|
Permission::JmapMailboxChanges
|
|
}
|
|
jmap_proto::method::changes::RequestArguments::Thread => {
|
|
Permission::JmapThreadChanges
|
|
}
|
|
jmap_proto::method::changes::RequestArguments::Identity => {
|
|
Permission::JmapIdentityChanges
|
|
}
|
|
jmap_proto::method::changes::RequestArguments::EmailSubmission => {
|
|
Permission::JmapEmailSubmissionChanges
|
|
}
|
|
jmap_proto::method::changes::RequestArguments::Quota => {
|
|
Permission::JmapQuotaChanges
|
|
}
|
|
},
|
|
RequestMethod::Copy(m) => match m.arguments {
|
|
jmap_proto::method::copy::RequestArguments::Email => Permission::JmapEmailCopy,
|
|
},
|
|
RequestMethod::CopyBlob(_) => Permission::JmapBlobCopy,
|
|
RequestMethod::ImportEmail(_) => Permission::JmapEmailImport,
|
|
RequestMethod::ParseEmail(_) => Permission::JmapEmailParse,
|
|
RequestMethod::QueryChanges(m) => match m.arguments {
|
|
jmap_proto::method::query::RequestArguments::Email(_) => {
|
|
Permission::JmapEmailQueryChanges
|
|
}
|
|
jmap_proto::method::query::RequestArguments::Mailbox(_) => {
|
|
Permission::JmapMailboxQueryChanges
|
|
}
|
|
jmap_proto::method::query::RequestArguments::EmailSubmission => {
|
|
Permission::JmapEmailSubmissionQueryChanges
|
|
}
|
|
jmap_proto::method::query::RequestArguments::SieveScript => {
|
|
Permission::JmapSieveScriptQueryChanges
|
|
}
|
|
jmap_proto::method::query::RequestArguments::Principal => {
|
|
Permission::JmapPrincipalQueryChanges
|
|
}
|
|
jmap_proto::method::query::RequestArguments::Quota => {
|
|
Permission::JmapQuotaQueryChanges
|
|
}
|
|
},
|
|
RequestMethod::Query(m) => match m.arguments {
|
|
jmap_proto::method::query::RequestArguments::Email(_) => Permission::JmapEmailQuery,
|
|
jmap_proto::method::query::RequestArguments::Mailbox(_) => {
|
|
Permission::JmapMailboxQuery
|
|
}
|
|
jmap_proto::method::query::RequestArguments::EmailSubmission => {
|
|
Permission::JmapEmailSubmissionQuery
|
|
}
|
|
jmap_proto::method::query::RequestArguments::SieveScript => {
|
|
Permission::JmapSieveScriptQuery
|
|
}
|
|
jmap_proto::method::query::RequestArguments::Principal => {
|
|
Permission::JmapPrincipalQuery
|
|
}
|
|
jmap_proto::method::query::RequestArguments::Quota => Permission::JmapQuotaQuery,
|
|
},
|
|
RequestMethod::SearchSnippet(_) => Permission::JmapSearchSnippet,
|
|
RequestMethod::ValidateScript(_) => Permission::JmapSieveScriptValidate,
|
|
RequestMethod::LookupBlob(_) => Permission::JmapBlobLookup,
|
|
RequestMethod::UploadBlob(_) => Permission::JmapBlobUpload,
|
|
RequestMethod::Echo(_) => Permission::JmapEcho,
|
|
RequestMethod::Error(_) => return Ok(()),
|
|
};
|
|
|
|
if self.has_permission(permission) {
|
|
Ok(())
|
|
} else {
|
|
Err(trc::JmapEvent::Forbidden
|
|
.into_err()
|
|
.details("You are not authorized to perform this action"))
|
|
}
|
|
}
|
|
|
|
pub fn as_resource_token(&self) -> ResourceToken {
|
|
ResourceToken {
|
|
account_id: self.primary_id,
|
|
quota: self.quota,
|
|
tenant: self.tenant,
|
|
}
|
|
}
|
|
|
|
pub fn update_size(mut self) -> Self {
|
|
self.obj_size = (std::mem::size_of::<AccessToken>()
|
|
+ (self.member_of.len() * std::mem::size_of::<u32>())
|
|
+ (self.access_to.len() * (std::mem::size_of::<u32>() + std::mem::size_of::<u64>()))
|
|
+ self.name.len()
|
|
+ self.description.as_ref().map_or(0, |v| v.len())
|
|
+ self.emails.iter().map(|v| v.len()).sum::<usize>()) as u64;
|
|
self
|
|
}
|
|
}
|