Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{#>TitleContactUsTextLayout}}
{{OrganizationName}} invited you to join their organization vault on Bitwarden, giving you secure access to shared passwords, passkeys, and other sensitive items your team relies on.

Your invite is still waiting! Accept it to get started.

Accept invite: {{Url}}
{{/TitleContactUsTextLayout}}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{#>TitleContactUsTextLayout}}
It looks like you haven't joined the {{OrganizationName}} vault on Bitwarden yet.

Accepting your invite gives you secure, encrypted access to the logins and other items your team has shared with you, so you can get to work without delay.

If you have questions about this invite, reach out to your admin at {{OrganizationName}}.

Accept invite: {{Url}}
{{/TitleContactUsTextLayout}}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{{#>TitleContactUsTextLayout}}
This is your final reminder to join the {{OrganizationName}} vault on Bitwarden. If you do not accept, your access will expire and you'll need to request a new invite from your admin to gain access.

Accept now to keep your access to shared logins, passkeys, and secure notes uninterrupted.

Accept invite: {{Url}}
{{/TitleContactUsTextLayout}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
<mj-include path="../components/admin-console-head.mjml" />
</mj-head>

<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-ac-hero
img-src="https://assets.bitwarden.com/email/v1/ac-spot-enterprise.png"
title="{{OrganizationName}} invited you to join them on Bitwarden"
button-text="<b>Accept invite</b>"
button-url="{{Url}}"
/>
</mj-wrapper>

<!-- Main Content -->
<mj-wrapper padding="5px 20px 8px 20px">
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
<mj-column>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
<b>{{OrganizationName}}</b> invited you to join their organization vault on
Bitwarden, giving you secure access to shared passwords, passkeys,
and other sensitive items your team relies on.
</mj-text>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
Your invite is still waiting! Accept it to get started.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>

<!-- Learn More Section -->
<mj-wrapper padding="8px 20px 10px 20px">
<mj-bw-ac-learn-more-footer />
</mj-wrapper>

<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
<mj-include path="../components/admin-console-head.mjml" />
</mj-head>

<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-ac-hero
img-src="https://assets.bitwarden.com/email/v1/ac-spot-enterprise.png"
title="{{OrganizationName}} invited you to join them on Bitwarden"
button-text="<b>Accept invite</b>"
button-url="{{Url}}"
/>
</mj-wrapper>

<!-- Main Content -->
<mj-wrapper padding="5px 20px 8px 20px">
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
<mj-column>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
It looks like you haven't joined the <b>{{OrganizationName}}</b> vault
on Bitwarden yet.
</mj-text>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
Accepting your invite gives you secure, encrypted access to the
logins and other items your team has shared with you, so you can
get to work without delay.
</mj-text>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
If you have questions about this invite, reach out to your admin
at {{OrganizationName}}.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>

<!-- Learn More Section -->
<mj-wrapper padding="8px 20px 10px 20px">
<mj-bw-ac-learn-more-footer />
</mj-wrapper>

<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml" />
<mj-include path="../components/admin-console-head.mjml" />
</mj-head>

<mj-body css-class="border-fix">
<!-- Blue Header Section -->
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-ac-hero
img-src="https://assets.bitwarden.com/email/v1/ac-spot-enterprise.png"
title="{{OrganizationName}} invited you to join them on Bitwarden"
button-text="<b>Accept invite</b>"
button-url="{{Url}}"
/>
</mj-wrapper>

<!-- Main Content -->
<mj-wrapper padding="5px 20px 8px 20px">
<mj-section background-color="#fff" padding="10px 10px 16px 10px">
<mj-column>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
This is your final reminder to join the <b>{{OrganizationName}}</b>
vault on Bitwarden. If you do not accept, your access will expire
and you'll need to request a new invite from your admin to gain
access.
</mj-text>
<mj-text
css-class="ac-text"
padding="15px 15px 0px 15px"
>
Accept now to keep your access to shared logins, passkeys, and
secure notes uninterrupted.
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>

<!-- Learn More Section -->
<mj-wrapper padding="8px 20px 10px 20px">
<mj-bw-ac-learn-more-footer />
</mj-wrapper>

<!-- Footer -->
<mj-include path="../../../components/footer.mjml" />
</mj-body>
</mjml>
39 changes: 39 additions & 0 deletions src/Core/Platform/Mail/HandlebarsMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,45 @@ public async Task SendUpdatedOrganizationInviteEmailsAsync(OrganizationInvitesIn
await EnqueueMailAsync(messageModels);
}

public async Task SendOrganizationInviteReminderEmailsAsync(OrganizationInvitesInfo orgInvitesInfo, int reminderNumber)
{
var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair =>
{
Debug.Assert(orgUserTokenPair.OrgUser.Email is not null);

var organizationName = CoreHelpers.PreventEmailAutoLinking(orgInvitesInfo.OrganizationName);

var (subject, templateName) = reminderNumber switch
{
1 => ($"Reminder: You're invited to join {orgInvitesInfo.OrganizationName} on Bitwarden",
"AdminConsole.OrganizationInvite.OrganizationInviteReminder1View"),
2 => ($"Don't miss out: Your {orgInvitesInfo.OrganizationName} invite is still waiting",
"AdminConsole.OrganizationInvite.OrganizationInviteReminder2View"),
3 => ($"Last chance: Your {orgInvitesInfo.OrganizationName} invite is about to expire",
"AdminConsole.OrganizationInvite.OrganizationInviteReminder3View"),
_ => throw new ArgumentOutOfRangeException(nameof(reminderNumber), reminderNumber,
"Reminder number must be 1, 2, or 3.")
};

var url = BuildInvitationUrl(orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token);
var expirationDate = $"{orgUserTokenPair.Token.ExpirationDate.ToLongDateString()} {orgUserTokenPair.Token.ExpirationDate.ToShortTimeString()} UTC";

var message = CreateDefaultMessage(subject, orgUserTokenPair.OrgUser.Email);

return new MailQueueMessage(message, templateName, new
{
OrganizationName = organizationName,
Email = orgUserTokenPair.OrgUser.Email,
ExpirationDate = expirationDate,
Url = url,
InviterEmail = orgInvitesInfo.InviterEmail,
CurrentYear = DateTime.UtcNow.Year.ToString()
});
});

await EnqueueMailAsync(messageModels);
}

public async Task SendUpdatedOrganizationConfirmedEmailAsync(Organization organization, string userEmail, bool accessSecretsManager = false)
{
var organizationName = organization.DisplayName();
Expand Down
6 changes: 6 additions & 0 deletions src/Core/Platform/Mail/IMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ Task SendTrialInitiationSignupEmailAsync(
/// <param name="orgInvitesInfo">The information required to send the organization invites.</param>
Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
Task SendUpdatedOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo);
/// <summary>
/// Sends a reminder email to the members of an organization who have not yet accepted their invite.
/// </summary>
/// <param name="orgInvitesInfo">The information required to send the organization invite reminders.</param>
/// <param name="reminderNumber">Which reminder is being sent: 1, 2, or 3.</param>
Task SendOrganizationInviteReminderEmailsAsync(OrganizationInvitesInfo orgInvitesInfo, int reminderNumber);
Task SendOrganizationMaxSeatLimitReachedEmailAsync(Organization organization, int maxSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAutoscaledEmailAsync(Organization organization, int initialSeatCount, IEnumerable<string> ownerEmails);
Task SendOrganizationAcceptedEmailAsync(Organization organization, string userIdentifier, IEnumerable<string> adminEmails, bool hasAccessSecretsManager = false);
Expand Down
5 changes: 5 additions & 0 deletions src/Core/Platform/Mail/NoopMailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ public Task SendUpdatedOrganizationInviteEmailsAsync(OrganizationInvitesInfo org
return Task.FromResult(0);
}

public Task SendOrganizationInviteReminderEmailsAsync(OrganizationInvitesInfo orgInvitesInfo, int reminderNumber)
{
return Task.FromResult(0);
}

public Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) =>
Task.CompletedTask;

Expand Down
99 changes: 99 additions & 0 deletions test/Core.Test/Services/HandlebarsMailServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Models.Mail;
using Bit.Core.Platform.Mail.Delivery;
Expand Down Expand Up @@ -567,6 +568,104 @@ public void GetCloudVaultSubscriptionUrl_ResolvesPerRegion(string cloudRegion, s
Assert.StartsWith(expectedVaultBase, result);
}

[Theory]
[InlineData(1, "Reminder: You're invited to join Acme.Corp on Bitwarden", "OrganizationInviteReminder1View")]
[InlineData(2, "Don't miss out: Your Acme.Corp invite is still waiting", "OrganizationInviteReminder2View")]
[InlineData(3, "Last chance: Your Acme.Corp invite is about to expire", "OrganizationInviteReminder3View")]
public async Task SendOrganizationInviteReminderEmailsAsync_SendsCorrectSubjectAndTemplate(
int reminderNumber, string expectedSubject, string expectedTemplateName)
{
// Arrange
_mailEnqueuingService
.EnqueueManyAsync(Arg.Any<IEnumerable<IMailQueueMessage>>(), Arg.Any<Func<IMailQueueMessage, Task>>())
.Returns(callInfo => Task.WhenAll(
callInfo.Arg<IEnumerable<IMailQueueMessage>>().Select(callInfo.Arg<Func<IMailQueueMessage, Task>>())));

var organization = new Organization { Id = Guid.NewGuid(), Name = "Acme.Corp", PlanType = PlanType.Free };
var orgUser = new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
Email = "member@example.com"
};
var token = new ExpiringToken("test_token", DateTime.UtcNow.AddDays(5));

var orgInvitesInfo = new OrganizationInvitesInfo(
organization,
orgSsoEnabled: false,
orgSsoLoginRequiredPolicyEnabled: false,
orgUserTokenPairs: new[] { (orgUser, token) },
orgUserHasExistingUserDict: new Dictionary<Guid, bool> { { orgUser.Id, false } },
inviterEmail: "inviter@example.com");

// Act
await _sut.SendOrganizationInviteReminderEmailsAsync(orgInvitesInfo, reminderNumber);

// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.ToEmails.Contains("member@example.com") &&
m.Subject == expectedSubject &&
m.HtmlContent.Contains("Acme.&#8204;Corp") &&
m.Category == $"AdminConsole.OrganizationInvite.{expectedTemplateName}"));
}

[Fact]
public async Task SendOrganizationInviteReminderEmailsAsync_MultipleMembers_SendsOneEmailPerMember()
{
// Arrange
_mailEnqueuingService
.EnqueueManyAsync(Arg.Any<IEnumerable<IMailQueueMessage>>(), Arg.Any<Func<IMailQueueMessage, Task>>())
.Returns(callInfo => Task.WhenAll(
callInfo.Arg<IEnumerable<IMailQueueMessage>>().Select(callInfo.Arg<Func<IMailQueueMessage, Task>>())));

var organization = new Organization { Id = Guid.NewGuid(), Name = "Acme Corp", PlanType = PlanType.Free };
var orgUser1 = new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, Email = "one@example.com" };
var orgUser2 = new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, Email = "two@example.com" };
var token = new ExpiringToken("test_token", DateTime.UtcNow.AddDays(5));

var orgInvitesInfo = new OrganizationInvitesInfo(
organization,
orgSsoEnabled: false,
orgSsoLoginRequiredPolicyEnabled: false,
orgUserTokenPairs: new[] { (orgUser1, token), (orgUser2, token) },
orgUserHasExistingUserDict: new Dictionary<Guid, bool> { { orgUser1.Id, false }, { orgUser2.Id, true } });

// Act
await _sut.SendOrganizationInviteReminderEmailsAsync(orgInvitesInfo, 1);

// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m => m.ToEmails.Contains("one@example.com")));
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m => m.ToEmails.Contains("two@example.com")));
}

[Fact]
public async Task SendOrganizationInviteReminderEmailsAsync_RequireSsoOrganization_BuildsUrlWithSsoIdentifier()
{
// Arrange
_mailEnqueuingService
.EnqueueManyAsync(Arg.Any<IEnumerable<IMailQueueMessage>>(), Arg.Any<Func<IMailQueueMessage, Task>>())
.Returns(callInfo => Task.WhenAll(
callInfo.Arg<IEnumerable<IMailQueueMessage>>().Select(callInfo.Arg<Func<IMailQueueMessage, Task>>())));

var organization = new Organization { Id = Guid.NewGuid(), Name = "Acme Corp", PlanType = PlanType.Free, Identifier = "acme-sso" };
var orgUser = new OrganizationUser { Id = Guid.NewGuid(), OrganizationId = organization.Id, Email = "member@example.com" };
var token = new ExpiringToken("test_token", DateTime.UtcNow.AddDays(5));

var orgInvitesInfo = new OrganizationInvitesInfo(
organization,
orgSsoEnabled: true,
orgSsoLoginRequiredPolicyEnabled: true,
orgUserTokenPairs: new[] { (orgUser, token) },
orgUserHasExistingUserDict: new Dictionary<Guid, bool> { { orgUser.Id, false } });

// Act
await _sut.SendOrganizationInviteReminderEmailsAsync(orgInvitesInfo, 1);

// Assert
await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is<MailMessage>(m =>
m.HtmlContent.Contains("orgSsoIdentifier=acme-sso")));
}

[Theory]
[InlineData(nameof(HandlebarsMailService.SendEmergencyAccessConfirmedEmailAsync))]
[InlineData(nameof(HandlebarsMailService.SendEmergencyAccessRecoveryApproved))]
Expand Down
Loading