diff --git a/.claude/skills/convert-email-mjml/SKILL.md b/.claude/skills/convert-email-mjml/SKILL.md new file mode 100644 index 000000000000..64643264bf8f --- /dev/null +++ b/.claude/skills/convert-email-mjml/SKILL.md @@ -0,0 +1,488 @@ +--- +name: convert-email-mjml +description: Convert a Handlebars email template to MJML format. Handles conversion, compilation, and artifact placement. +disable-model-invocation: false +user-invocable:true +--- + +# Handlebars-to-MJML Email Conversion + +Convert an existing Handlebars email template to the new MJML-based email system. + +## Input + +The user will provide a path to an existing Handlebars email template: +- Example: `src/Core/MailTemplates/Handlebars/Billing/SomeEmail.html.hbs` +- Or they may just provide the template name + +The path provided is: **$ARGUMENTS** + +## Required Context + +Before starting, read these files to understand the conversion process: + +1. **@docs/plans/email-migrations/handlebars-to-mjml-transition.md** - Complete conversion guide +2. **@src/Core/MailTemplates/Mjml/README.md** - MJML build process and structure + +## Conversion Process + +### Step 1: Read and Analyze the Original Template + +- Read the original Handlebars template +- Identify which layout partial it uses (`FullHtmlLayout`, `ProviderFull`, etc.) +- Extract all Handlebars variables (e.g., `{{Url}}`, `{{Name}}`) +- Identify conditional logic (`{{#if}}`, `{{#unless}}`, `{{#each}}`) +- Note any custom Handlebars helpers used (`eq`, `date`, `usd`, etc.) + +### Step 2: Convert to MJML + +Follow the mapping guide from the transition doc: + +**Basic Structure:** +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Key Conversions:** +- `{{#>FullHtmlLayout}}` → Standard MJML skeleton with `mj-include` for logo +- `{{#>ProviderFull}}` → Use `` or `` instead of logo +- Table rows → `` + `` + `` +- Inline-styled `` buttons → `` +- `
` spacers → `` or padding attributes + +**Default Styles (do NOT copy from Handlebars):** +`head.mjml` already sets these globally — do not add them as explicit attributes on `` or other elements unless you are intentionally overriding the default: +- `font-family`: `'Helvetica Neue', Helvetica, Arial, sans-serif` +- `font-size`: `16px` +- `color` (text): `#1B2029` +- `mj-button` background: `#175ddc` + +Only add explicit style attributes when the value differs from these defaults. + +**Handlebars Logic:** +- Simple variables inside content → Leave as-is (e.g., `{{Name}}`, `{{{Url}}}`) +- Conditionals wrapping MJML tags → Wrap with `{{#if ...}}` and `{{/if}}` +- Conditionals inside text → Place directly in `` content + +**File Location:** +- Original: `src/Core/MailTemplates/Handlebars/{Category}/{EmailName}.html.hbs` +- New MJML: `src/Core/MailTemplates/Mjml/emails/{Category}/{EmailName}.mjml` + +Write the new MJML file to the correct location. + +### Step 3: Compile and Test + +1. **Compile MJML to HTML:** + ```bash + cd src/Core/MailTemplates/Mjml + npm run build:hbs + ``` + +2. **Verify compilation:** + - Check that compilation succeeded with no errors + - Read the output file: `src/Core/MailTemplates/Mjml/out/{Category}/{EmailName}.html.hbs` + - Verify all Handlebars expressions are preserved (e.g., `{{{Url}}}`, `{{#if}}`) + - Confirm responsive media queries are generated + - Check email client compatibility code (Outlook VML) is included + +### Step 4: Locate the ViewModel + +Find the corresponding C# ViewModel class: + +1. Search for files matching the email name pattern: + ```bash + find src/Core -name "*{EmailName}*Model.cs" + ``` + +2. Or search for references to the email template: + ```bash + grep -r "{EmailName}" src/Core --include="*.cs" + ``` + +3. The ViewModel is typically located at: + - `src/Core/Models/Mail/{Category}/{EmailName}Model.cs` + +### Step 5: Create Folder and Copy Compiled Artifact + +Each new MJML-based email gets its **own dedicated subfolder** alongside the ViewModel (following the pattern of `Billing/Renewal/Premium/`, `Billing/Renewal/Families2019Renewal/`, etc.). + +1. **Create the subfolder:** + ``` + src/Core/Models/Mail/{Category}/{EmailName}/ + ``` + +2. **Copy the compiled artifact** and rename it to match the View class name: + - **From:** `src/Core/MailTemplates/Mjml/out/{Category}/{EmailName}.html.hbs` + - **To:** `src/Core/Models/Mail/{Category}/{EmailName}/{EmailName}MailView.html.hbs` + +3. **Move the existing text template** to the same folder, renamed to match the View class: + - **From:** `src/Core/MailTemplates/Handlebars/{Category}/{EmailName}.text.hbs` + - **To:** `src/Core/Models/Mail/{Category}/{EmailName}/{EmailName}MailView.text.hbs` + - **Do NOT modify its content** — copy it exactly as-is; text templates are hand-authored and not generated + +The folder and all three files (`.cs`, `.html.hbs`, `.text.hbs`) must share the same directory so the `IMailer` system can discover the templates at runtime. + +### Step 6: Migrate from HandlebarsMailService to IMailer + +After the MJML template is created, you must migrate any code that uses the old `IMailService` / `HandlebarsMailService` to the new `IMailer` approach. + +#### Step 6.1: Identify the Old Mail Service Method + +Search for the method in `HandlebarsMailService` (or `IMailService` interface) that sends this email: + +```bash +grep -n "Send.*{EmailName}" src/Core/Platform/Mail/IMailService.cs +grep -n "Send.*{EmailName}" src/Core/Platform/Mail/HandlebarsMailService.cs +``` + +**Example:** For `BusinessUnitConversionInvite`, the method is: +```csharp +// In IMailService.cs +Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); + +// In HandlebarsMailService.cs (line ~1160) +public async Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email) +{ + var message = CreateDefaultMessage("Set Up Business Unit", email); + var model = new BusinessUnitConversionInviteModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + OrganizationId = organization.Id.ToString(), + Email = WebUtility.UrlEncode(email), + Token = WebUtility.UrlEncode(token) + }; + await AddMessageContentAsync(message, "Billing.BusinessUnitConversionInvite", model); + message.Category = "BusinessUnitConversionInvite"; + await _mailDeliveryService.SendEmailAsync(message); +} +``` + +#### Step 6.2: Update the ViewModel and Create the Mail Class + +The old ViewModel inherits from `BaseMailModel`. Update it to inherit from `BaseMailView` and add the Mail class in the **same file** (following the pattern in `BaseMail.cs`): + +**Before:** +```csharp +using Bit.Core.Models.Mail; + +namespace Bit.Core.Models.Mail.Billing; + +public class BusinessUnitConversionInviteModel : BaseMailModel +{ + public string OrganizationId { get; set; } + public string Email { get; set; } + public string Token { get; set; } + + public string Url => + $"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}"; +} +``` + +**After (both classes in one file, new subfolder):** +```csharp +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Billing.BusinessUnitConversionInvite; + +#nullable enable + +/// +/// Email sent to invite users to set up a Business Unit Portal. +/// +public class BusinessUnitConversionInviteMail : BaseMail +{ + public override string Subject { get; set; } = "Set Up Business Unit"; +} + +/// +/// View model for Business Unit Conversion Invite email template. +/// +public class BusinessUnitConversionInviteMailView : BaseMailView +{ + public required string OrganizationId { get; init; } + public required string Email { get; init; } + public required string Token { get; init; } + public required string WebVaultUrl { get; init; } + + public string Url => + $"{WebVaultUrl}/providers/setup-business-unit?organizationId={OrganizationId}&email={Email}&token={Token}"; +} +``` + +**Key changes:** +- **New dedicated subfolder**: `src/Core/Models/Mail/{Category}/{EmailName}/` (e.g., `Billing/BusinessUnitConversionInvite/`) +- **Both classes in the same file** (follows existing Renewal email pattern) +- **View class name** uses `*MailView` suffix (e.g., `BusinessUnitConversionInviteMailView`) +- **Namespace** includes the subfolder (e.g., `Bit.Core.Models.Mail.Billing.BusinessUnitConversionInvite`) +- View inherits from `BaseMailView` instead of `BaseMailModel` +- Enable nullable reference types (`#nullable enable`) +- Properties use `required` and `init` for immutability +- `WebVaultUrl` is now a required property (no longer inherited from BaseMailModel) +- Remove `SiteName` if not used (BaseMailView only provides `CurrentYear`) +- Mail class defines `Subject` (Category is optional — only add if the old method set one explicitly) + +**File location:** +- Old: `src/Core/Models/Mail/Billing/BusinessUnitConversionInviteModel.cs` +- New: `src/Core/Models/Mail/Billing/BusinessUnitConversionInvite/BusinessUnitConversionInviteMailView.cs` + +#### Step 6.3: Place Template Files in the New Subfolder + +Template files must match the **View class name** and live in the **same subfolder** as the `.cs` file: + +``` +src/Core/Models/Mail/{Category}/{EmailName}/ +├── {EmailName}MailView.cs +├── {EmailName}MailView.html.hbs ← compiled MJML artifact (renamed) +└── {EmailName}MailView.text.hbs ← copied from Handlebars source +``` + +This is exactly what Step 5 produces. If Step 5 was followed, no additional renaming is needed here. + +#### Step 6.4: Find and Replace All Invocations + +Search for all places where the old mail service method is called: + +```bash +grep -rn "SendBusinessUnitConversionInviteAsync" src/ --include="*.cs" +``` + +**Old approach (HandlebarsMailService):** +```csharp +public class SomeService +{ + private readonly IMailService _mailService; + + public SomeService(IMailService mailService) + { + _mailService = mailService; + } + + public async Task InviteToBusinessUnit(Organization org, string email, string token) + { + await _mailService.SendBusinessUnitConversionInviteAsync(org, token, email); + } +} +``` + +**New approach (IMailer):** +```csharp +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Models.Mail.Billing; +using System.Net; + +public class SomeService +{ + private readonly IMailer _mailer; + private readonly IGlobalSettings _globalSettings; + + public SomeService(IMailer mailer, IGlobalSettings globalSettings) + { + _mailer = mailer; + _globalSettings = globalSettings; + } + + public async Task InviteToBusinessUnit(Organization org, string email, string token) + { + var mail = new BusinessUnitConversionInviteMail + { + ToEmails = [email], + View = new BusinessUnitConversionInviteView + { + OrganizationId = org.Id.ToString(), + Email = WebUtility.UrlEncode(email), + Token = WebUtility.UrlEncode(token), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + } + }; + + await _mailer.SendEmail(mail); + } +} +``` + +**Key differences:** +- Inject `IMailer` instead of `IMailService` +- Inject `IGlobalSettings` to get `WebVaultUrl` (no longer on ViewModel base class) +- Create mail object with `ToEmails` and `View` properties +- Call `_mailer.SendEmail(mail)` instead of `_mailService.Send*Async(...)` +- ViewModel instantiation is explicit (better type safety) + +#### Step 6.5: Update Dependency Injection + +Ensure the consuming service has access to `IMailer`. In most cases, this is already registered globally. + +If not registered, add to `ServiceCollectionExtensions.cs`: +```csharp +services.AddMailer(); // Registers IMailer and IMailRenderer +``` + +#### Step 6.6: Remove or Deprecate Old Method + +Once all invocations are migrated: + +1. **Mark the old method as obsolete** in `IMailService`: + ```csharp + [Obsolete("Use IMailer with BusinessUnitConversionInviteMail instead")] + Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); + ``` + +2. **Do NOT delete** the method from `HandlebarsMailService` yet—it may be in use by other environments +3. File a follow-up task to remove the method after a deprecation period + +#### Step 6.7: Update Tests + +Find all tests for services that were updated in Step 6.4 and replace `IMailService` mocks with `IMailer` mocks. + +**Find affected test files:** +```bash +grep -rn "Send{EmailName}Async\|IMailService" test/ --include="*.cs" -l +``` + +**Old test pattern (mocking IMailService):** +```csharp +public class SomeServiceTests +{ + private readonly IMailService _mailService; + private readonly SomeService _sut; + + public SomeServiceTests() + { + _mailService = Substitute.For(); + _sut = new SomeService(_mailService); + } + + [Fact] + public async Task InviteToBusinessUnit_SendsEmail() + { + // Act + await _sut.InviteToBusinessUnit(org, email, token); + + // Assert + await _mailService.Received(1) + .SendBusinessUnitConversionInviteAsync(org, token, email); + } +} +``` + +**New test pattern (mocking IMailer):** +```csharp +public class SomeServiceTests +{ + private readonly IMailer _mailer; + private readonly IGlobalSettings _globalSettings; + private readonly SomeService _sut; + + public SomeServiceTests() + { + _mailer = Substitute.For(); + _globalSettings = Substitute.For(); + _sut = new SomeService(_mailer, _globalSettings); + } + + [Fact] + public async Task InviteToBusinessUnit_SendsEmail() + { + // Arrange + var vaultUrl = "https://vault.example.com/#"; + _globalSettings.BaseServiceUri.VaultWithHash.Returns(vaultUrl); + + // Act + await _sut.InviteToBusinessUnit(org, email, token); + + // Assert + await _mailer.Received(1).SendEmail( + Arg.Is(m => + m.ToEmails.Contains(email) && + m.View.OrganizationId == org.Id.ToString() && + m.View.WebVaultUrl == vaultUrl)); + } +} +``` + +**Key changes in tests:** +- Replace `IMailService` field and constructor arg with `IMailer` +- Add `IGlobalSettings` mock if the service now requires it (set up `VaultWithHash` return value) +- Replace `Received(1).SendXxxAsync(...)` with `Received(1).SendEmail(Arg.Is(...))` +- Use `Arg.Is<>` to assert the `ToEmails` list and critical `View` properties +- Update `using` directives: add the new mail namespace, remove old `IMailService` import if no longer used +- If the test class uses AutoFixture/`[Theory]` with `[BitAutoData]`, update `[Frozen]` attributes accordingly: + ```csharp + // Old + [BitAutoData] SutProvider sutProvider, ... + // sutProvider.GetDependency().Received(1).SendXxxAsync(...) + + // New + [BitAutoData] SutProvider sutProvider, ... + // sutProvider.GetDependency().Received(1).SendEmail(Arg.Is(...)) + ``` + +### Step 7: Verification Checklist + +Confirm the following before completing: + +- [ ] MJML file created in correct location under `emails/` directory +- [ ] All Handlebars variables from original template are preserved +- [ ] Conditional logic (`{{#if}}`, `{{#unless}}`, `{{#each}}`) is properly wrapped with `` where needed +- [ ] Custom helpers (`eq`, `date`, `usd`) are used identically to original +- [ ] MJML compilation completed with no errors +- [ ] Compiled `.html.hbs` contains Handlebars expressions intact +- [ ] Compiled artifact copied to location next to ViewModel +- [ ] ViewModel location identified and confirmed +- [ ] ViewModel updated to inherit from `BaseMailView` +- [ ] Mail class created inheriting from `BaseMail` +- [ ] Template files renamed to match View class name +- [ ] All invocations of old mail service method migrated to IMailer +- [ ] Tests updated: `IMailService` mocks replaced with `IMailer`, assertions updated to `SendEmail(Arg.Is(...))` +- [ ] Old method marked as obsolete (if appropriate) + +## Output Summary + +Provide a clear summary including: + +1. **Original template**: Full path to source Handlebars file +2. **New MJML file**: Location of created MJML source +3. **Compiled output**: Location of `.html.hbs` artifact +4. **ViewModel**: Location of associated ViewModel class +5. **Key conversions**: Summary of major structural changes +6. **Variables preserved**: List of Handlebars expressions carried over +7. **Next steps**: Any manual testing or integration work needed + +## Important Notes + +- **Do NOT create or modify text email templates** (`.text.hbs` files) — they are hand-authored separately; only copy the existing one to the new location +- **Do NOT copy inline styles from Handlebars templates**: `head.mjml` defines global defaults (`font-size: 16px`, `color: #1B2029`, `font-family`). Use plain `` without style attributes unless overriding a default. +- **Triple-stash URLs**: Always use `{{{Url}}}` for URLs to prevent HTML encoding +- **Validation level**: Build uses `strict` validation; `mj-raw` must be in legal positions +- **No backwards compatibility needed**: This is a new pipeline, not a replacement +- **Style inheritance**: Most styles come from `head.mjml` - only override what differs + +## Reference Files + +- Conversion guide: `docs/plans/email-migrations/handlebars-to-mjml-transition.md` +- MJML README: `src/Core/MailTemplates/Mjml/README.md` +- Platform Mail README: `src/Core/Platform/Mail/README.md` +- MailTemplates README: `src/Core/MailTemplates/README.md` diff --git a/.claude/skills/convert-email-mjml/template.mjml b/.claude/skills/convert-email-mjml/template.mjml new file mode 100644 index 000000000000..36f595749cdb --- /dev/null +++ b/.claude/skills/convert-email-mjml/template.mjml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + Your text content here with {{HandlebarsVariables}} + + + + + {{#if SomeCondition}} + Conditional text here + {{else}} + Alternative text + {{/if}} + + + + + Button Text + + + + + + + {{#unless SomeCondition}} + + + + This entire section is conditional + + + + {{/unless}} + + + + + + + + + + + + diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs index 12d370395cb2..387015443115 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Providers/RemoveOrganizationFromProviderCommand.cs @@ -13,8 +13,12 @@ using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Mail.Provider.ProviderUpdatePaymentMethod; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; using Stripe; namespace Bit.Commercial.Core.AdminConsole.Providers; @@ -22,7 +26,8 @@ namespace Bit.Commercial.Core.AdminConsole.Providers; public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand { private readonly IEventService _eventService; - private readonly IMailService _mailService; + private readonly IMailer _mailer; + private readonly IGlobalSettings _globalSettings; private readonly IOrganizationRepository _organizationRepository; private readonly IProviderOrganizationRepository _providerOrganizationRepository; private readonly IStripeAdapter _stripeAdapter; @@ -34,7 +39,8 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv public RemoveOrganizationFromProviderCommand( IEventService eventService, - IMailService mailService, + IMailer mailer, + IGlobalSettings globalSettings, IOrganizationRepository organizationRepository, IProviderOrganizationRepository providerOrganizationRepository, IStripeAdapter stripeAdapter, @@ -45,7 +51,8 @@ public RemoveOrganizationFromProviderCommand( IPricingClient pricingClient) { _eventService = eventService; - _mailService = mailService; + _mailer = mailer; + _globalSettings = globalSettings; _organizationRepository = organizationRepository; _providerOrganizationRepository = providerOrganizationRepository; _stripeAdapter = stripeAdapter; @@ -176,10 +183,16 @@ private async Task ResetOrganizationBillingAsync( await _subscriberService.RemovePaymentSource(organization); } - await _mailService.SendProviderUpdatePaymentMethod( - organization.Id, - organization.Name, - provider.Name!, - organizationOwnerEmails); + await _mailer.SendEmail(new ProviderUpdatePaymentMethodMail + { + ToEmails = organizationOwnerEmails, + View = new ProviderUpdatePaymentMethodMailView + { + OrganizationId = organization.Id.ToString(), + OrganizationName = CoreHelpers.SanitizeForEmail(organization.Name), + ProviderName = CoreHelpers.SanitizeForEmail(provider.Name!), + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash + } + }); } } diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs index 07420c895e81..03e81f42cfa0 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/BusinessUnitConverter.cs @@ -15,8 +15,9 @@ using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Mail.Billing.BusinessUnitConversionInvite; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.AspNetCore.DataProtection; @@ -30,7 +31,7 @@ public class BusinessUnitConverter( IDataProtectionProvider dataProtectionProvider, GlobalSettings globalSettings, ILogger logger, - IMailService mailService, + IMailer mailer, IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IPricingClient pricingClient, @@ -319,7 +320,19 @@ private async Task SendInviteAsync( var token = _dataProtector.Protect( $"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail); + var mail = new BusinessUnitConversionInviteMail + { + ToEmails = [providerAdminEmail], + View = new BusinessUnitConversionInviteMailView + { + OrganizationId = organization.Id.ToString(), + Email = System.Net.WebUtility.UrlEncode(providerAdminEmail), + Token = System.Net.WebUtility.UrlEncode(token), + WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash + } + }; + + await mailer.SendEmail(mail); } private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync( diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs index 810429d65885..241ab0bcdfb5 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/ProviderFeatures/RemoveOrganizationFromProviderCommandTests.cs @@ -11,6 +11,8 @@ using Bit.Core.Billing.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Mail.Provider.ProviderUpdatePaymentMethod; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Test.Billing.Mocks; @@ -123,12 +125,9 @@ await sutProvider.GetDependency().Received(1) await sutProvider.GetDependency().Received(1) .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - await sutProvider.GetDependency().Received(1) - .SendProviderUpdatePaymentMethod( - organization.Id, - organization.Name, - provider.Name, - Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.FirstOrDefault() == "a@example.com")); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() .UpdateCustomerAsync(Arg.Any(), Arg.Any()); @@ -186,12 +185,9 @@ await sutProvider.GetDependency().Received(1) await sutProvider.GetDependency().Received(1) .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - await sutProvider.GetDependency().Received(1) - .SendProviderUpdatePaymentMethod( - organization.Id, - organization.Name, - provider.Name, - Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.FirstOrDefault() == "a@example.com")); } [Theory, BitAutoData] @@ -275,12 +271,9 @@ await sutProvider.GetDependency().Received(1) await sutProvider.GetDependency().Received(1) .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - await sutProvider.GetDependency().Received(1) - .SendProviderUpdatePaymentMethod( - organization.Id, - organization.Name, - provider.Name, - Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.FirstOrDefault() == "a@example.com")); } [Theory, BitAutoData] @@ -364,12 +357,9 @@ await sutProvider.GetDependency().Received(1) await sutProvider.GetDependency().Received(1) .LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed); - await sutProvider.GetDependency().Received(1) - .SendProviderUpdatePaymentMethod( - organization.Id, - organization.Name, - provider.Name, - Arg.Is>(emails => emails.FirstOrDefault() == "a@example.com")); + await sutProvider.GetDependency().Received(1) + .SendEmail(Arg.Is(mail => + mail.ToEmails.FirstOrDefault() == "a@example.com")); } private static Subscription GetSubscription(string subscriptionId, string customerId) => diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index 48b971a032a8..ecaafb00051b 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -15,8 +15,9 @@ using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Mail.Billing.BusinessUnitConversionInvite; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; -using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Test.Billing.Mocks; using Bit.Core.Utilities; @@ -34,7 +35,7 @@ public class BusinessUnitConverterTests private readonly IDataProtectionProvider _dataProtectionProvider = Substitute.For(); private readonly GlobalSettings _globalSettings = new(); private readonly ILogger _logger = Substitute.For>(); - private readonly IMailService _mailService = Substitute.For(); + private readonly IMailer _mailer = Substitute.For(); private readonly IOrganizationRepository _organizationRepository = Substitute.For(); private readonly IOrganizationUserRepository _organizationUserRepository = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); @@ -50,7 +51,7 @@ public class BusinessUnitConverterTests _dataProtectionProvider, _globalSettings, _logger, - _mailService, + _mailer, _organizationRepository, _organizationUserRepository, _pricingClient, @@ -280,10 +281,11 @@ await _providerUserRepository.Received(1).CreateAsync( argument.Status == ProviderUserStatusType.Invited && argument.Type == ProviderUserType.ProviderAdmin)); - await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( - organization, - token, - user.Email); + await _mailer.Received(1).SendEmail( + Arg.Is(m => + m.ToEmails.Contains(user.Email) && + m.View.OrganizationId == organization.Id.ToString() && + m.View.Token == System.Net.WebUtility.UrlEncode(token))); } [Theory, BitAutoData] @@ -348,10 +350,11 @@ public async Task ResendConversionInvite_ConversionInProgress_Succeeds( await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); - await _mailService.Received(1).SendBusinessUnitConversionInviteAsync( - organization, - token, - providerAdminEmail); + await _mailer.Received(1).SendEmail( + Arg.Is(m => + m.ToEmails.Contains(providerAdminEmail) && + m.View.OrganizationId == organization.Id.ToString() && + m.View.Token == System.Net.WebUtility.UrlEncode(token))); } [Theory, BitAutoData] @@ -365,10 +368,8 @@ public async Task ResendConversionInvite_NoConversionInProgress_DoesNothing( await businessUnitConverter.ResendConversionInvite(organization, providerAdminEmail); - await _mailService.DidNotReceiveWithAnyArgs().SendBusinessUnitConversionInviteAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); + await _mailer.DidNotReceiveWithAnyArgs().SendEmail( + Arg.Any()); } #endregion diff --git a/docs/plans/email-migrations/handlebars-to-mjml-transition.md b/docs/plans/email-migrations/handlebars-to-mjml-transition.md new file mode 100644 index 000000000000..1ff59c6905bb --- /dev/null +++ b/docs/plans/email-migrations/handlebars-to-mjml-transition.md @@ -0,0 +1,641 @@ +# Handlebars-to-MJML Email Transition Guide + +## Context + +Bitwarden's `HandlebarsMailService` is deprecated in favor of the new `IMailer` system. New emails should be authored as `.mjml` source files that compile to `.html.hbs` artifacts consumed by Handlebars at runtime. This document provides a systematic reference for converting existing Handlebars email templates to MJML, based on the official MJML documentation, the official Handlebars documentation, and Bitwarden's established MJML patterns. + +## Related Documentation + +Before converting templates, familiarize yourself with these key resources: + +- **[MailTemplates README](../../../src/Core/MailTemplates/README.md)** - Overview of the mail template system and directory structure +- **[Mjml README](../../../src/Core/MailTemplates/Mjml/README.md)** - MJML build process, custom components, and development workflow +- **[Platform Mail README](../../../src/Core/Platform/Mail/README.md)** - IMailer system architecture and integration guide + +--- + +## 1. Architecture Overview + +### Current (Deprecated) Pipeline + +``` +Template (.html.hbs) + Layout partial (Full.html.hbs / ProviderFull.html.hbs) + → HandlebarsDotNet compiles at runtime + → Final HTML delivered to email client +``` + +Templates use `{{#>FullHtmlLayout}}` or `{{#>ProviderFull}}` block partials that inject content into a hand-coded HTML layout containing all styling, the logo, the footer, and email-client workarounds. + +### New Pipeline + +``` +Source (.mjml) + shared components (head.mjml, footer.mjml, mj-bw-* custom components) + → `npm run build:hbs` compiles MJML to .html.hbs + → .html.hbs artifact placed next to ViewModel class + → HandlebarsDotNet compiles at runtime with ViewModel data + → Final HTML delivered to email client +``` + +Key insight: **MJML and Handlebars are not competing—they run in sequence.** MJML compiles structural markup at build time; Handlebars resolves `{{ variables }}` at runtime. The `{{ }}` expressions pass through MJML compilation untouched because MJML does not interpret them. + +--- + +## 2. Structural Mapping Reference + +### Layout → MJML Skeleton + +The old layout partials (`Full.html.hbs`, `ProviderFull.html.hbs`) are replaced by a standard MJML skeleton that uses `mj-include` for shared components: + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +For "Provider" style emails (blue header bar), use the `` or `` custom components instead of the logo include. + +### Element-by-Element Mapping + +| Old Handlebars/HTML | MJML Equivalent | Notes | +|---|---|---| +| `{{#>FullHtmlLayout}}...{{/FullHtmlLayout}}` | `` skeleton + `mj-include` for head/logo/footer | Layout is now implicit in the MJML structure | +| `{{#>ProviderFull}}...{{/ProviderFull}}` | Skeleton with `` or `` | Custom component renders the blue header bar | +| `` with `content-block` rows | `` + `` + `` | Each `
` → one `` block | +| Inline-styled `` button | `` | Inherits `background-color: #175ddc` from `head.mjml` defaults | +| `
` spacers | `` or padding on `mj-text` | Prefer padding attributes over spacer elements | +| Social icon `` | `` | Already handled by shared footer component with `mj-social` | +| ` + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ +
You have been invited to set up a new Business Unit Portal within Bitwarden.
+ +
+ + + + + + + +
+ + Set Up Business Unit Portal Now + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + + + + + + \ No newline at end of file diff --git a/src/Core/Models/Mail/Billing/BusinessUnitConversionInvite/BusinessUnitConversionInviteMailView.text.hbs b/src/Core/Models/Mail/Billing/BusinessUnitConversionInvite/BusinessUnitConversionInviteMailView.text.hbs new file mode 100644 index 000000000000..b2973f32c22f --- /dev/null +++ b/src/Core/Models/Mail/Billing/BusinessUnitConversionInvite/BusinessUnitConversionInviteMailView.text.hbs @@ -0,0 +1,5 @@ +{{#>BasicTextLayout}} + You have been invited to set up a new Business Unit Portal within Bitwarden. To continue, click the following link: + + {{{Url}}} +{{/BasicTextLayout}} diff --git a/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.cs b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.cs new file mode 100644 index 000000000000..effea2d9adc5 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.cs @@ -0,0 +1,28 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Provider.ProviderInvoiceUpcoming; + +#nullable enable + +/// +/// Email sent to providers when their upcoming invoice is approaching. +/// +public class ProviderInvoiceUpcomingMail : BaseMail +{ + public override string Subject { get; set; } = "Your upcoming Bitwarden invoice"; +} + +/// +/// View model for Provider Invoice Upcoming email template. +/// +public class ProviderInvoiceUpcomingMailView : BaseMailView +{ + public required decimal AmountDue { get; init; } + public required DateTime DueDate { get; init; } + public required List Items { get; init; } + public string? CollectionMethod { get; init; } + public bool HasPaymentMethod { get; init; } + public string? PaymentMethodDescription { get; init; } + public string UpdateBillingInfoUrl { get; init; } = "https://bitwarden.com/help/update-billing-info/"; + public string ContactUrl { get; init; } = "https://bitwarden.com/contact/"; +} diff --git a/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.html.hbs b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.html.hbs new file mode 100644 index 000000000000..de5dc4733017 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.html.hbs @@ -0,0 +1,841 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
{{#if (eq CollectionMethod "send_invoice")}} +
+ Your subscription will renew soon +
+
+ On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a + summary of the charges including tax. +
+ {{else}} +
+ Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}} +
+ {{#if HasPaymentMethod}} +
+ To avoid any interruption in service, please ensure your + {{PaymentMethodDescription}} can be charged for the following amount: +
+ {{else}} +
+ To avoid any interruption in service, please add a payment method + that can be charged for the following amount: +
+ {{/if}} + {{/if}}
+ +
+ +
+ + +
+ +
+ + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
{{usd AmountDue}}
+ +
+ +
+ + +
+ +
+ + + + {{/unless}}{{#if Items}}{{#unless (eq CollectionMethod "send_invoice")}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Summary Of Charges +
+ {{#each Items}} +
{{this}}
+ {{/each}}
+ +
+ +
+ + +
+ +
+ + + + {{/unless}}{{/if}}{{#if (eq CollectionMethod "send_invoice")}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
To avoid any interruption in service for you or your clients, please pay the invoice by the due + date, or contact Bitwarden Customer Support to sign up for auto-pay.
+ +
+ +
+ + +
+ +
+ + + + {{/if}}{{#unless (eq CollectionMethod "send_invoice")}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Update payment method + +
+ +
+ +
+ + +
+ +
+ + + + {{/unless}}{{#if (eq CollectionMethod "send_invoice")}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Contact Bitwarden Support + +
+ +
+ +
+ + +
+ +
+ + + + {{/if}} + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
For assistance managing your subscription, please visit + + the Help Center + + or + + contact Bitwarden Customer Support + .
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.text.hbs b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.text.hbs new file mode 100644 index 000000000000..c666e287a554 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderInvoiceUpcoming/ProviderInvoiceUpcomingMailView.text.hbs @@ -0,0 +1,41 @@ +{{#>BasicTextLayout}} +{{#if (eq CollectionMethod "send_invoice")}} +Your subscription will renew soon + +On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax. +{{else}} +Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}} + + {{#if HasPaymentMethod}} +To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount: + {{else}} +To avoid any interruption in service, please add a payment method that can be charged for the following amount: + {{/if}} + +{{usd AmountDue}} +{{/if}} +{{#if Items}} +{{#unless (eq CollectionMethod "send_invoice")}} + +Summary Of Charges +------------------ +{{#each Items}} +{{this}} +{{/each}} +{{/unless}} +{{/if}} + +{{#if (eq CollectionMethod "send_invoice")}} +To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay. + +Contact Bitwarden Support: {{{ContactUrl}}} + +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{else}} + +{{/if}} + +{{#unless (eq CollectionMethod "send_invoice")}} +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{/unless}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.cs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.cs new file mode 100644 index 000000000000..616f678562c4 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.cs @@ -0,0 +1,28 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.Provider.ProviderUpdatePaymentMethod; + +#nullable enable + +/// +/// Email sent to organization owners when their organization is removed from a provider, +/// asking them to update their billing payment method. +/// +public class ProviderUpdatePaymentMethodMail : BaseMail +{ + public override string Subject { get; set; } = "Update your billing information"; +} + +/// +/// View model for Provider Update Payment Method email template. +/// +public class ProviderUpdatePaymentMethodMailView : BaseMailView +{ + public required string OrganizationId { get; init; } + public required string OrganizationName { get; init; } + public required string ProviderName { get; init; } + public required string WebVaultUrl { get; init; } + + public string PaymentMethodUrl => + $"{WebVaultUrl}/organizations/{OrganizationId}/billing/payment-method"; +} diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.html.hbs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.html.hbs new file mode 100644 index 000000000000..f32bb302db73 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.html.hbs @@ -0,0 +1,538 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information.
+ +
+ +
To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> + Payment Method.
+ +
+ +
For more information, please refer to the following help article: + Update billing information for organizations
+ +
+ + + + + + + +
+ + Add payment method + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © {{ CurrentYear }} Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.text.hbs b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.text.hbs new file mode 100644 index 000000000000..1cc82c4ddac4 --- /dev/null +++ b/src/Core/Models/Mail/Provider/ProviderUpdatePaymentMethod/ProviderUpdatePaymentMethodMailView.text.hbs @@ -0,0 +1,5 @@ +Your organization, {{OrganizationName}}, is no longer managed by {{ProviderName}}. Please update your billing information. + +To maintain your subscription, update your organization billing information by navigating to the web vault -> Organization -> Billing -> Payment Method. + +Or click the following link: {{{PaymentMethodUrl}}} diff --git a/src/Core/Platform/Mail/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs index e07e4bad2994..32f3193fd664 100644 --- a/src/Core/Platform/Mail/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -88,6 +88,7 @@ Task SendInvoiceUpcoming( DateTime dueDate, List items, bool mentionInvoices); + [Obsolete("Use IMailer with ProviderInvoiceUpcomingMail instead")] Task SendProviderInvoiceUpcoming( IEnumerable emails, decimal amount, @@ -112,10 +113,12 @@ Task SendProviderInvoiceUpcoming( Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); + [Obsolete("Use IMailer with BusinessUnitConversionInviteMail instead")] Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); Task SendProviderConfirmedEmailAsync(string providerName, string email); Task SendProviderUserRemoved(string providerName, string email); + [Obsolete("Use IMailer with ProviderUpdatePaymentMethodMail instead")] Task SendProviderUpdatePaymentMethod( Guid organizationId, string organizationName, diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index 8b4e0bd5dfc6..a8e6e6476076 100644 --- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -139,6 +139,45 @@ private async Task InitializeHandlebarsAsync() var titleContactUsTextLayoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.TitleContactUs.text.hbs"); handlebars.RegisterTemplate("TitleContactUsTextLayout", titleContactUsTextLayoutSource); + // Register custom helpers used by mail templates. + handlebars.RegisterHelper("date", (writer, context, parameters) => + { + if (parameters.Length == 0 || parameters[0] is not DateTime) + { + writer.WriteSafeString(string.Empty); + return; + } + if (parameters.Length > 1 && parameters[1] is string format) + { + writer.WriteSafeString(((DateTime)parameters[0]).ToString(format)); + } + else + { + writer.WriteSafeString(((DateTime)parameters[0]).ToString()); + } + }); + + handlebars.RegisterHelper("usd", (writer, context, parameters) => + { + if (parameters.Length == 0 || parameters[0] is not decimal) + { + writer.WriteSafeString(string.Empty); + return; + } + writer.WriteSafeString(((decimal)parameters[0]).ToString("C")); + }); + + handlebars.RegisterHelper("eq", (context, arguments) => + { + if (arguments.Length != 2) + { + return false; + } + var value1 = arguments[0]?.ToString(); + var value2 = arguments[1]?.ToString(); + return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase); + }); + return handlebars; } } diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 82d6c8acfd49..228d4ceee966 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -14,6 +14,7 @@ using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal; using Bit.Core.Models.Mail.Billing.Renewal.Premium; +using Bit.Core.Models.Mail.Provider.ProviderInvoiceUpcoming; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; @@ -617,14 +618,15 @@ await _stripeFacade.Received(1).UpdateSubscription( Arg.Is(o => o.AutomaticTax.Enabled == true)); // Verify provider invoice email was sent - await _mailService.Received(1).SendProviderInvoiceUpcoming( - Arg.Is>(e => e.Contains("provider@example.com")), - Arg.Is(amount => amount == invoice.AmountDue / 100M), - Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), - Arg.Is>(items => items.Count == invoice.Lines.Data.Count), - Arg.Is(s => s == subscription.CollectionMethod), - Arg.Is(b => b == true), - Arg.Is(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}")); + await _mailer.Received(1).SendEmail( + Arg.Is(m => + m.ToEmails.Contains("provider@example.com") && + m.View.AmountDue == invoice.AmountDue / 100M && + m.View.DueDate == invoice.NextPaymentAttempt.Value && + m.View.Items.Count == invoice.Lines.Data.Count && + m.View.CollectionMethod == subscription.CollectionMethod && + m.View.HasPaymentMethod == true && + m.View.PaymentMethodDescription == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}")); } [Fact] @@ -944,14 +946,7 @@ public async Task HandleAsync_WhenProviderNotFound_DoesNothing() await _providerRepository.Received(1).GetByIdAsync(_providerId); // Verify no provider emails were sent - await _mailService.DidNotReceive().SendProviderInvoiceUpcoming( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + await _mailer.DidNotReceive().SendEmail(Arg.Any()); } [Fact]