1
0
mirror of https://github.com/bitwarden/server synced 2025-12-30 07:03:42 +00:00

Merge branch 'main' into SM-1571-DisableSMAdsForUsers

This commit is contained in:
cd-bitwarden
2025-10-28 14:10:57 -04:00
committed by GitHub
96 changed files with 17627 additions and 1014 deletions

View File

@@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public Guid? CollectionId => Event.CollectionId;
public Guid? GroupId => Event.GroupId;
public Guid? PolicyId => Event.PolicyId;
public Guid? IdempotencyId => Event.IdempotencyId;
public Guid? ProviderId => Event.ProviderId;
public Guid? ProviderUserId => Event.ProviderUserId;
public Guid? ProviderOrganizationId => Event.ProviderOrganizationId;
public Guid? InstallationId => Event.InstallationId;
public Guid? SecretId => Event.SecretId;
public Guid? ProjectId => Event.ProjectId;
public Guid? ServiceAccountId => Event.ServiceAccountId;
public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId;
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; }

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
/// <summary>
/// Interface defining common organization details properties shared between
/// regular organization users and provider organization users for profile endpoints.
/// </summary>
public interface IProfileOrganizationDetails
{
Guid? UserId { get; set; }
Guid OrganizationId { get; set; }
string Name { get; set; }
bool Enabled { get; set; }
PlanType PlanType { get; set; }
bool UsePolicies { get; set; }
bool UseSso { get; set; }
bool UseKeyConnector { get; set; }
bool UseScim { get; set; }
bool UseGroups { get; set; }
bool UseDirectory { get; set; }
bool UseEvents { get; set; }
bool UseTotp { get; set; }
bool Use2fa { get; set; }
bool UseApi { get; set; }
bool UseResetPassword { get; set; }
bool SelfHost { get; set; }
bool UsersGetPremium { get; set; }
bool UseCustomPermissions { get; set; }
bool UseSecretsManager { get; set; }
int? Seats { get; set; }
short? MaxCollections { get; set; }
short? MaxStorageGb { get; set; }
string? Identifier { get; set; }
string? Key { get; set; }
string? ResetPasswordKey { get; set; }
string? PublicKey { get; set; }
string? PrivateKey { get; set; }
string? SsoExternalId { get; set; }
string? Permissions { get; set; }
Guid? ProviderId { get; set; }
string? ProviderName { get; set; }
ProviderType? ProviderType { get; set; }
bool? SsoEnabled { get; set; }
string? SsoConfig { get; set; }
bool UsePasswordManager { get; set; }
bool LimitCollectionCreation { get; set; }
bool LimitCollectionDeletion { get; set; }
bool AllowAdminAccessToAllCollectionItems { get; set; }
bool UseRiskInsights { get; set; }
bool LimitItemDeletion { get; set; }
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
}

View File

@@ -1,20 +1,18 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails
public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public string? Key { get; set; }
public Enums.OrganizationUserStatusType Status { get; set; }
public Enums.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public PlanType PlanType { get; set; }
public string SsoExternalId { get; set; }
public string Identifier { get; set; }
public string Permissions { get; set; }
public string ResetPasswordKey { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? SsoExternalId { get; set; }
public string? Identifier { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public string? FamilySponsorshipFriendlyName { get; set; }
public bool? SsoEnabled { get; set; }
public string SsoConfig { get; set; }
public string? SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; }

View File

@@ -1,19 +1,16 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
public class ProviderUserOrganizationDetails
public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails
public bool SelfHost { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public string? Key { get; set; }
public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; }
public bool Enabled { get; set; }
public string Identifier { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? Identifier { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public PlanType PlanType { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
@@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; }
public ProviderType? ProviderType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool? SsoEnabled { get; set; }
public string? SsoConfig { get; set; }
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
}

View File

@@ -65,7 +65,7 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -64,7 +64,7 @@ public class OtpTokenProvider<TOptions>(
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -0,0 +1,14 @@
namespace Bit.Core.Billing.Constants;
public static class BitPayConstants
{
public static class InvoiceStatuses
{
public const string Complete = "complete";
}
public static class PosDataKeys
{
public const string AccountCredit = "accountCredit:1";
}
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities;
using Bit.Core.Settings;
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands;
using static BitPayConstants;
public interface ICreateBitPayInvoiceForCreditCommand
{
Task<BillingCommandResult<string>> Run(
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
{
var (name, email, posData) = GetSubscriberInformation(subscriber);
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
var invoice = new Invoice
{
Buyer = new Buyer { Email = email, Name = name },
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
ExtendedNotifications = true,
FullNotifications = true,
ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl,
NotificationUrl = notificationUrl,
PosData = posData,
Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
ISubscriber subscriber) => subscriber switch
{
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"),
User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"),
Organization organization => (organization.Name, organization.BillingEmail,
$"organizationId:{organization.Id},accountCredit:1"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
$"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
_ => throw new ArgumentOutOfRangeException(nameof(subscriber))
};
}

View File

@@ -0,0 +1,11 @@
namespace Bit.Core.Billing.Payment.Models;
public record NonTokenizedPaymentMethod
{
public NonTokenizablePaymentMethodType Type { get; set; }
}
public enum NonTokenizablePaymentMethodType
{
AccountCredit,
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OneOf;
namespace Bit.Core.Billing.Payment.Models;
[JsonConverter(typeof(PaymentMethodJsonConverter))]
public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(input)
{
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
public bool IsTokenized => IsT0;
public bool IsNonTokenized => IsT1;
}
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>
{
public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
if (!element.TryGetProperty("type", out var typeProperty))
{
throw new JsonException("PaymentMethod requires a 'type' property");
}
var type = typeProperty.GetString();
if (Enum.TryParse<TokenizablePaymentMethodType>(type, true, out var tokenizedType) &&
Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType))
{
var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null;
if (string.IsNullOrEmpty(token))
{
throw new JsonException("TokenizedPaymentMethod requires a 'token' property");
}
return new TokenizedPaymentMethod { Type = tokenizedType, Token = token };
}
if (Enum.TryParse<NonTokenizablePaymentMethodType>(type, true, out var nonTokenizedType) &&
Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType))
{
return new NonTokenizedPaymentMethod { Type = nonTokenizedType };
}
throw new JsonException($"Unknown payment method type: {type}");
}
public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options)
{
writer.WriteStartObject();
value.Switch(
tokenized =>
{
writer.WriteString("type",
tokenized.Type.ToString().ToLowerInvariant()
);
writer.WriteString("token", tokenized.Token);
},
nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); }
);
writer.WriteEndObject();
}
}

View File

@@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
using Customer = Stripe.Customer;
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Premium.Commands;
@@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb);
}
@@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
public Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () =>
{
@@ -74,6 +75,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0.");
}
// Note: A customer will already exist if the customer has purchased account credits.
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
@@ -82,18 +84,31 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
switch (paymentMethod)
{
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
paymentMethod.Switch(
tokenized =>
{
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (tokenized)
{
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}
},
nonTokenized =>
{
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
}
});
user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id;
@@ -109,9 +124,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
});
private async Task<Customer> CreateCustomerAsync(User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress)
{
if (paymentMethod.IsNonTokenized)
{
_logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id);
throw new BillingException();
}
var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions
{
@@ -153,13 +174,14 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var braintreeCustomerId = "";
// We have checked that the payment method is tokenized, so we can safely cast it.
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethod.Type)
switch (paymentMethod.AsT0.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token }))
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token }))
.FirstOrDefault();
if (setupIntent == null)
@@ -173,19 +195,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}
case TokenizablePaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token;
break;
}
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
default:
{
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString());
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString());
throw new BillingException();
}
}
@@ -203,18 +225,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
if (paymentMethod.IsTokenized)
{
case TokenizablePaymentMethodType.BankAccount:
{
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
switch (paymentMethod.AsT0.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id);
break;
}
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
{
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
break;
}
}
}
}
}

View File

@@ -151,6 +151,7 @@ public static class FeatureFlagKeys
public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor";
public const string Otp6Digits = "pm-18612-otp-6-digits";
public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email";
public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users";
public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods";
public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword =
"pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password";
@@ -241,6 +242,7 @@ public static class FeatureFlagKeys
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
/* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive";

View File

@@ -16,7 +16,9 @@
<ItemGroup>
<EmbeddedResource Include="licensing.cer" />
<EmbeddedResource Include="licensing_dev.cer" />
<EmbeddedResource Include="MailTemplates\Handlebars\**\*.hbs" />
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
<ItemGroup>
@@ -72,7 +74,7 @@
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
<Folder Include="Properties\" />

View File

@@ -11,12 +11,24 @@ public class OrganizationReport : ITableObject<Guid>
public Guid OrganizationId { get; set; }
public string ReportData { get; set; } = string.Empty;
public DateTime CreationDate { get; set; } = DateTime.UtcNow;
public string ContentEncryptionKey { get; set; } = string.Empty;
public string? SummaryData { get; set; } = null;
public string? ApplicationData { get; set; } = null;
public string? SummaryData { get; set; }
public string? ApplicationData { get; set; }
public DateTime RevisionDate { get; set; } = DateTime.UtcNow;
public int? ApplicationCount { get; set; }
public int? ApplicationAtRiskCount { get; set; }
public int? CriticalApplicationCount { get; set; }
public int? CriticalApplicationAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public int? MemberAtRiskCount { get; set; }
public int? CriticalMemberCount { get; set; }
public int? CriticalMemberAtRiskCount { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? CriticalPasswordCount { get; set; }
public int? CriticalPasswordAtRiskCount { get; set; }
public void SetNewId()
{

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@
"prettier": "prettier --cache --write ."
},
"dependencies": {
"mjml": "4.15.3",
"mjml": "4.16.1",
"mjml-core": "4.15.3"
},
"devDependencies": {

View File

@@ -0,0 +1,54 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// BaseMail describes a model for emails. It contains metadata about the email such as recipients,
/// subject, and an optional category for processing at the upstream email delivery service.
///
/// Each BaseMail must have a view model that inherits from BaseMailView. The view model is used to
/// generate the text part and HTML body.
/// </summary>
public abstract class BaseMail<TView> where TView : BaseMailView
{
/// <summary>
/// Email recipients.
/// </summary>
public required IEnumerable<string> ToEmails { get; set; }
/// <summary>
/// The subject of the email.
/// </summary>
public abstract string Subject { get; }
/// <summary>
/// An optional category for processing at the upstream email delivery service.
/// </summary>
public string? Category { get; }
/// <summary>
/// Allows you to override and ignore the suppression list for this email.
///
/// Warning: This should be used with caution, valid reasons are primarily account recovery, email OTP.
/// </summary>
public virtual bool IgnoreSuppressList { get; } = false;
/// <summary>
/// View model for the email body.
/// </summary>
public required TView View { get; set; }
}
/// <summary>
/// Each MailView consists of two body parts: a text part and an HTML part and the filename must be
/// relative to the viewmodel and match the following pattern:
/// - `{ClassName}.html.hbs` for the HTML part
/// - `{ClassName}.text.hbs` for the text part
/// </summary>
public abstract class BaseMailView
{
/// <summary>
/// Current year.
/// </summary>
public string CurrentYear => DateTime.UtcNow.Year.ToString();
}

View File

@@ -0,0 +1,80 @@
#nullable enable
using System.Collections.Concurrent;
using System.Reflection;
using HandlebarsDotNet;
namespace Bit.Core.Platform.Mailer;
public class HandlebarMailRenderer : IMailRenderer
{
/// <summary>
/// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once.
/// </summary>
private readonly Lazy<Task<IHandlebars>> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Helper function that returns the handlebar instance.
/// </summary>
private Task<IHandlebars> GetHandlebars() => _handlebarsTask.Value;
/// <summary>
/// This dictionary is used to cache compiled templates in a thread-safe manner.
/// </summary>
private readonly ConcurrentDictionary<string, Lazy<Task<HandlebarsTemplate<object, object>>>> _templateCache = new();
public async Task<(string html, string txt)> RenderAsync(BaseMailView model)
{
var html = await CompileTemplateAsync(model, "html");
var txt = await CompileTemplateAsync(model, "text");
return (html, txt);
}
private async Task<string> CompileTemplateAsync(BaseMailView model, string type)
{
var templateName = $"{model.GetType().FullName}.{type}.hbs";
var assembly = model.GetType().Assembly;
// GetOrAdd is atomic - only one Lazy will be stored per templateName.
// The Lazy with ExecutionAndPublication ensures the compilation happens exactly once.
var lazyTemplate = _templateCache.GetOrAdd(
templateName,
key => new Lazy<Task<HandlebarsTemplate<object, object>>>(
() => CompileTemplateInternalAsync(assembly, key),
LazyThreadSafetyMode.ExecutionAndPublication));
var template = await lazyTemplate.Value;
return template(model);
}
private async Task<HandlebarsTemplate<object, object>> CompileTemplateInternalAsync(Assembly assembly, string templateName)
{
var source = await ReadSourceAsync(assembly, templateName);
var handlebars = await GetHandlebars();
return handlebars.Compile(source);
}
private static async Task<string> ReadSourceAsync(Assembly assembly, string template)
{
if (assembly.GetManifestResourceNames().All(f => f != template))
{
throw new FileNotFoundException("Template not found: " + template);
}
await using var s = assembly.GetManifestResourceStream(template)!;
using var sr = new StreamReader(s);
return await sr.ReadToEndAsync();
}
private static async Task<IHandlebars> InitializeHandlebarsAsync()
{
var handlebars = Handlebars.Create();
// TODO: Do we still need layouts with MJML?
var assembly = typeof(HandlebarMailRenderer).Assembly;
var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs");
handlebars.RegisterTemplate("FullHtmlLayout", layoutSource);
return handlebars;
}
}

View File

@@ -0,0 +1,7 @@
#nullable enable
namespace Bit.Core.Platform.Mailer;
public interface IMailRenderer
{
Task<(string html, string txt)> RenderAsync(BaseMailView model);
}

View File

@@ -0,0 +1,15 @@
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Generic mailer interface for sending email messages.
/// </summary>
public interface IMailer
{
/// <summary>
/// Sends an email message.
/// </summary>
/// <param name="message"></param>
public Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView;
}

View File

@@ -0,0 +1,32 @@
using Bit.Core.Models.Mail;
using Bit.Core.Services;
namespace Bit.Core.Platform.Mailer;
#nullable enable
public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) : IMailer
{
public async Task SendEmail<T>(BaseMail<T> message) where T : BaseMailView
{
var content = await renderer.RenderAsync(message.View);
var metadata = new Dictionary<string, object>();
if (message.IgnoreSuppressList)
{
metadata.Add("SendGridBypassListManagement", true);
}
var mailMessage = new MailMessage
{
ToEmails = message.ToEmails,
Subject = message.Subject,
MetaData = metadata,
HtmlContent = content.html,
TextContent = content.txt,
Category = message.Category,
};
await mailDeliveryService.SendEmailAsync(mailMessage);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Bit.Core.Platform.Mailer;
#nullable enable
/// <summary>
/// Extension methods for adding the Mailer feature to the service collection.
/// </summary>
public static class MailerServiceCollectionExtensions
{
/// <summary>
/// Adds the Mailer services to the <see cref="IServiceCollection"/>.
/// This includes the mail renderer and mailer for sending templated emails.
/// This method is safe to be run multiple times.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/> to add services to.</param>
/// <returns>The <see cref="IServiceCollection"/> for additional chaining.</returns>
public static IServiceCollection AddMailer(this IServiceCollection services)
{
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
return services;
}
}

View File

@@ -0,0 +1,200 @@
# Mailer
The Mailer feature provides a structured, type-safe approach to sending emails in the Bitwarden server application. It
uses Handlebars templates to render both HTML and plain text email content.
## Architecture
The Mailer system consists of four main components:
1. **IMailer** - Service interface for sending emails
2. **BaseMail<TView>** - Abstract base class defining email metadata (recipients, subject, category)
3. **BaseMailView** - Abstract base class for email template view models
4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`)
## How To Use
1. Define a view model that inherits from `BaseMailView` with properties for template data
2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline,
`/src/Core/MailTemplates/Mjml`.
3. Define an email class that inherits from `BaseMail<TView>` with metadata like subject
4. Use `IMailer.SendEmail()` to render and send the email
## Creating a New Email
### Step 1: Define the Email & View Model
Create a class that inherits from `BaseMailView`:
```csharp
using Bit.Core.Platform.Mailer;
namespace MyApp.Emails;
public class WelcomeEmailView : BaseMailView
{
public required string UserName { get; init; }
public required string ActivationUrl { get; init; }
}
public class WelcomeEmail : BaseMail<WelcomeEmailView>
{
public override string Subject => "Welcome to Bitwarden";
}
```
### Step 2: Create Handlebars Templates
Create two template files as embedded resources next to your view model. **Important**: The file names must be located
directly next to the `ViewClass` and match the name of the view.
**WelcomeEmailView.html.hbs** (HTML version):
```handlebars
<h1>Welcome, {{ UserName }}!</h1>
<p>Thank you for joining Bitwarden.</p>
<p>
<a href="{{ ActivationUrl }}">Activate your account</a>
</p>
<p><small>&copy; {{ CurrentYear }} Bitwarden Inc.</small></p>
```
**WelcomeEmailView.text.hbs** (plain text version):
```handlebars
Welcome, {{ UserName }}!
Thank you for joining Bitwarden.
Activate your account: {{ ActivationUrl }}
<EFBFBD> {{ CurrentYear }} Bitwarden Inc.
```
**Important**: Template files must be configured as embedded resources in your `.csproj`:
```xml
<ItemGroup>
<EmbeddedResource Include="**\*.hbs" />
</ItemGroup>
```
### Step 3: Send the Email
Inject `IMailer` and send the email, this may be done in a service, command or some other application layer.
```csharp
public class SomeService
{
private readonly IMailer _mailer;
public SomeService(IMailer mailer)
{
_mailer = mailer;
}
public async Task SendWelcomeEmailAsync(string email, string userName, string activationUrl)
{
var mail = new WelcomeEmail
{
ToEmails = [email],
View = new WelcomeEmailView
{
UserName = userName,
ActivationUrl = activationUrl
}
};
await _mailer.SendEmail(mail);
}
}
```
## Advanced Features
### Multiple Recipients
Send to multiple recipients by providing multiple email addresses:
```csharp
var mail = new WelcomeEmail
{
ToEmails = ["user1@example.com", "user2@example.com"],
View = new WelcomeEmailView { /* ... */ }
};
```
### Bypass Suppression List
For critical emails like account recovery or email OTP, you can bypass the suppression list:
```csharp
public class PasswordResetEmail : BaseMail<PasswordResetEmailView>
{
public override string Subject => "Reset Your Password";
public override bool IgnoreSuppressList => true; // Use with caution
}
```
**Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails.
### Email Categories
Optionally categorize emails for processing at the upstream email delivery service:
```csharp
public class MarketingEmail : BaseMail<MarketingEmailView>
{
public override string Subject => "Latest Updates";
public string? Category => "marketing";
}
```
## Built-in View Properties
All view models inherit from `BaseMailView`, which provides:
- **CurrentYear** - The current UTC year (useful for copyright notices)
```handlebars
<footer>&copy; {{ CurrentYear }} Bitwarden Inc.</footer>
```
## Template Naming Convention
Templates must follow this naming convention:
- HTML template: `{ViewModelFullName}.html.hbs`
- Text template: `{ViewModelFullName}.text.hbs`
For example, if your view model is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be:
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.html.hbs`
- `Bit.Core.Auth.Models.Mail.VerifyEmailView.text.hbs`
## Dependency Injection
Register the Mailer services in your DI container using the extension method:
```csharp
using Bit.Core.Platform.Mailer;
services.AddMailer();
```
Or manually register the services:
```csharp
using Microsoft.Extensions.DependencyInjection.Extensions;
services.TryAddSingleton<IMailRenderer, HandlebarMailRenderer>();
services.TryAddSingleton<IMailer, Mailer>();
```
## Performance Notes
- **Template caching** - `HandlebarMailRenderer` automatically caches compiled templates
- **Lazy initialization** - Handlebars is initialized only when first needed
- **Thread-safe** - The renderer is thread-safe for concurrent email rendering

View File

@@ -508,9 +508,15 @@
<data name="UserIdAndTokenMismatch" xml:space="preserve">
<value>Supplied userId and token did not match.</value>
</data>
<data name="UserShouldBeFound" xml:space="preserve">
<value>User should have been defined by this point.</value>
</data>
<data name="CouldNotFindOrganization" xml:space="preserve">
<value>Could not find organization for '{0}'</value>
</data>
<data name="CouldNotFindOrganizationUser" xml:space="preserve">
<value>Could not find organization user for user '{0}' organization '{1}'</value>
</data>
<data name="NoSeatsAvailable" xml:space="preserve">
<value>No seats available for organization, '{0}'</value>
</data>

View File

@@ -677,6 +677,7 @@ public class GlobalSettings : IGlobalSettings
public bool Production { get; set; }
public string Token { get; set; }
public string NotificationUrl { get; set; }
public string WebhookKey { get; set; }
}
public class InstallationSettings : IInstallationSettings

View File

@@ -1,30 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Settings;
namespace Bit.Core.Utilities;
public class BitPayClient
{
private readonly BitPayLight.BitPay _bpClient;
public BitPayClient(GlobalSettings globalSettings)
{
if (CoreHelpers.SettingHasValue(globalSettings.BitPay.Token))
{
_bpClient = new BitPayLight.BitPay(globalSettings.BitPay.Token,
globalSettings.BitPay.Production ? BitPayLight.Env.Prod : BitPayLight.Env.Test);
}
}
public Task<BitPayLight.Models.Invoice.Invoice> GetInvoiceAsync(string id)
{
return _bpClient.GetInvoice(id);
}
public Task<BitPayLight.Models.Invoice.Invoice> CreateInvoiceAsync(BitPayLight.Models.Invoice.Invoice invoice)
{
return _bpClient.CreateInvoice(invoice);
}
}