diff --git a/Api.SeaHavenIndustries.Tests/SendMessageTests.cs b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs
new file mode 100644
index 0000000..806bcd1
--- /dev/null
+++ b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs
@@ -0,0 +1,105 @@
+using System.Net;
+using Api.SeaHavenIndustries.Helper;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Moq;
+using SendGrid;
+using SendGrid.Helpers.Mail;
+using Xunit;
+
+namespace Api.SeaHavenIndustries.Tests;
+
+public sealed class SendMessageTests
+{
+ private const string Recipient = "taylor@example.com";
+ private const string Subject = "You're invited to Seahaven";
+ private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}";
+
+ [Theory]
+ [InlineData(HttpStatusCode.OK, true)]
+ [InlineData(HttpStatusCode.Accepted, true)]
+ [InlineData(HttpStatusCode.BadRequest, false)]
+ [InlineData(HttpStatusCode.Unauthorized, false)]
+ [InlineData(HttpStatusCode.TooManyRequests, false)]
+ [InlineData(HttpStatusCode.InternalServerError, false)]
+ public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered)
+ {
+ var sender = new StubbedSendMessage(status, new CapturingLogger());
+
+ Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "
Hi
"));
+ Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "Hi
"));
+ Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 }));
+ Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "Hi
", null));
+ }
+
+ [Fact]
+ public async Task ARejectedSend_LogsOnlyTheStatusCode()
+ {
+ var logger = new CapturingLogger();
+ var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger);
+
+ Assert.False(await sender.SendEMail(Recipient, Subject, "secret link
"));
+
+ var entry = Assert.Single(logger.Entries);
+ Assert.Contains("400", entry);
+ Assert.DoesNotContain(Recipient, entry);
+ Assert.DoesNotContain(Subject, entry);
+ Assert.DoesNotContain("secret link", entry);
+ Assert.DoesNotContain("errors", entry);
+ }
+
+ [Fact]
+ public async Task AFailedSend_LogsOnlyTheExceptionType()
+ {
+ var logger = new CapturingLogger();
+ var sender = new StubbedSendMessage(
+ new HttpRequestException($"could not reach SendGrid for {Recipient}"),
+ logger);
+
+ Assert.False(await sender.SendEMail(Recipient, Subject, "Hi
"));
+
+ var entry = Assert.Single(logger.Entries);
+ Assert.Contains(nameof(HttpRequestException), entry);
+ Assert.DoesNotContain(Recipient, entry);
+ }
+
+ private sealed class StubbedSendMessage : SendMessage
+ {
+ private readonly Mock _client = new();
+
+ public StubbedSendMessage(HttpStatusCode status, ILogger logger)
+ : base(new ConfigurationBuilder().Build(), logger)
+ {
+ _client
+ .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null));
+ }
+
+ public StubbedSendMessage(Exception failure, ILogger logger)
+ : base(new ConfigurationBuilder().Build(), logger)
+ {
+ _client
+ .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(failure);
+ }
+
+ protected override ISendGridClient CreateClient(string? apiKey) => _client.Object;
+ }
+
+ private sealed class CapturingLogger : ILogger
+ {
+ public List Entries { get; } = new();
+
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter) =>
+ Entries.Add($"{formatter(state, exception)} {exception}");
+ }
+}
diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs
index 08db640..5bab7ee 100644
--- a/Api.SeaHavenIndustries/Helper/SendMessage.cs
+++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs
@@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper
public class SendMessage : IEmailSender
{
private IConfiguration _configuration;
+ private readonly ILogger _logger;
//string keysapi = "";
- public SendMessage(IConfiguration configuration)
+ public SendMessage(IConfiguration configuration, ILogger logger)
{
_configuration = configuration;
+ _logger = logger;
//keysapi = _configuration.GetValue("SendGrid:ApiKey");
}
+ protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey);
+
+ ///
+ /// SendGrid reports a rejected message through the status code, not an exception.
+ /// Only the status is logged: never the recipient, subject or response body.
+ ///
+ private bool Delivered(Response response)
+ {
+ if (response.IsSuccessStatusCode)
+ return true;
+
+ _logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode);
+ return false;
+ }
+
+ private bool Failed(Exception ex)
+ {
+ _logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name);
+ return false;
+ }
+
public async Task SendEMail(string emailTo, string subject, string body)
{
try
@@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue("SendGrid:ApiKey");
//var apiKey = "";
- var client = new SendGridClient(apiKey);
+ var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper
var htmlContent = body;
var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent);
var response = await client.SendEmailAsync(msg);
- var dd = response.Body.ReadAsStringAsync();
- return true;
+ return Delivered(response);
}
catch (Exception ex)
{
- return false;
+ return Failed(ex);
}
}
public async Task SendEmailAsync(string emailTo, string subject, string htmlBody)
@@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper
var apiKey = _configuration.GetValue("SendGrid:ApiKey");
//var apiKey = "";
- var client = new SendGridClient(apiKey);
+ var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries");
//var subject = "Sending with SendGrid is Fun";
var to = new EmailAddress(emailTo, "");
@@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.Attachments = new List { attachment };
var response = await client.SendEmailAsync(msg);
-
- var dd = response.Body.ReadAsStringAsync();
- return true;
+ return Delivered(response);
}
catch (Exception ex)
{
- return false;
+ return Failed(ex);
}
}
public async Task SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo)
@@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper
try
{
var apiKey = _configuration.GetValue("SendGrid:ApiKey");
- var client = new SendGridClient(apiKey);
+ var client = CreateClient(apiKey);
var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries");
var to = new EmailAddress(emailTo, "");
@@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper
msg.SetReplyTo(new EmailAddress(replyTo));
var response = await client.SendEmailAsync(msg);
- return true;
+ return Delivered(response);
}
catch (Exception ex)
{
- return false;
+ return Failed(ex);
}
}
}
diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs
index 32ac851..dce7af1 100644
--- a/SeaHaven.DataServices/Implementation/UserDataService.cs
+++ b/SeaHaven.DataServices/Implementation/UserDataService.cs
@@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation
.Where(permissionOverride => permissionOverride.UserId == id)
.ExecuteDeleteAsync(cancellationToken);
+ await _context.TeamMemberInvites
+ .Where(invite => invite.UserId == id)
+ .ExecuteDeleteAsync(cancellationToken);
+
await _context.Users
.Where(existingUser => existingUser.Id == id)
.ExecuteDeleteAsync(cancellationToken);
diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs
index 330254b..3b355ba 100644
--- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs
+++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs
@@ -89,16 +89,28 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService
if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email))
return ResendFailure("Only pending team members can be re-invited.");
- var (invite, issued) = NewInvite(user.Id);
- await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
-
- var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
- if (!await SendAsync(issued, user.Email, name, cancellationToken))
+ if (!await ReissueAsync(user, cancellationToken))
return ResendFailure("The invite could not be emailed. Try again.");
return new TeamMemberInviteResendOutcomeDTO { Success = true };
}
+ public async Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(user);
+ ArgumentException.ThrowIfNullOrWhiteSpace(user.Email);
+
+ var (invite, issued) = NewInvite(user.Id);
+ await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken);
+
+ // A deactivated member cannot register; the admin re-sends the invite after reactivating them.
+ if (user.IsDeleted == true)
+ return true;
+
+ var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim();
+ return await SendAsync(issued, user.Email, name, cancellationToken);
+ }
+
private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId)
{
var now = NowUtc();
diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs
index 2f62818..d15a347 100644
--- a/SeaHaven.Services/Implementation/TeamMemberService.cs
+++ b/SeaHaven.Services/Implementation/TeamMemberService.cs
@@ -199,7 +199,8 @@ public sealed class TeamMemberService : ITeamMemberService
user.UniqueName = request.IsActive ? ActiveStatus : "Inactive";
user.IsDeleted = !request.IsActive;
- if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase))
+ var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase);
+ if (emailChanged)
{
var emailResult = await _userManager.SetEmailAsync(user, email!);
if (!emailResult.Succeeded)
@@ -237,6 +238,10 @@ public sealed class TeamMemberService : ITeamMemberService
await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken);
}
+ // An invite sent to the old address, and any code confirmed through it, must not register the new one.
+ if (emailChanged && user.PendingRegistration == true)
+ await _inviteService.ReissueAsync(user, cancellationToken);
+
return await OperationSuccessAsync(user, caller, cancellationToken);
}
diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
index 9b12928..54eb702 100644
--- a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
+++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using Data.SeaHavenIndustries;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces;
@@ -15,6 +16,13 @@ public interface ITeamMemberInviteService
string name,
CancellationToken cancellationToken);
+ ///
+ /// Revokes the member's open invites, including any email confirmation made through them,
+ /// and emails a new invite to their current address unless they are deactivated.
+ /// Returns false when that email failed.
+ ///
+ Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken);
+
/// Admin-only: revokes a pending member's open invites and emails a new one.
Task ResendAsync(
string userId,
diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
new file mode 100644
index 0000000..521d7a4
--- /dev/null
+++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs
@@ -0,0 +1,106 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Data.SeaHavenIndustries;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Interfaces;
+
+namespace SeaHavenIndustries.Tests;
+
+/// What happens to a member's invites when an admin edits or deletes that member.
+public sealed class TeamMemberInviteLifecycleTests
+{
+ private const string Email = "taylor@example.com";
+ private const string CorrectedEmail = "taylor.reed@example.com";
+ private const string Password = "Abc1!x";
+
+ [Fact]
+ public async Task DeletingAnInvitedMember_RemovesTheMemberAndTheirInvites()
+ {
+ await using var host = await TeamMemberInviteTestHost.CreateAsync();
+ var (userId, token) = await host.AddPendingMemberAsync(Email);
+ await host.SendCodeAsync(token, Email);
+ Assert.True((await ResendInviteAsync(host, userId)).Success);
+ Assert.Equal(2, (await host.InvitesAsync(userId)).Count);
+
+ var deleted = await host.InScopeAsync(provider => provider.GetRequiredService()
+ .DeleteUserAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
+
+ Assert.True(deleted.Success, deleted.Error);
+ Assert.Empty(await host.InvitesAsync(userId));
+ Assert.False(await host.InScopeAsync(provider => provider.GetRequiredService()
+ .Users.AnyAsync(user => user.Id == userId)));
+ }
+
+ [Fact]
+ public async Task ChangingAPendingMembersEmail_RevokesTheOldInviteAndItsConfirmation()
+ {
+ await using var host = await TeamMemberInviteTestHost.CreateAsync();
+ var (userId, oldToken) = await host.AddPendingMemberAsync(Email);
+ await host.ConfirmEmailAsync(oldToken, Email);
+
+ var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin");
+ Assert.True(updated.Success, updated.Error);
+
+ Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
+ (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status);
+ Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite,
+ (await host.RegistrationAsync(service => service.SendCodeAsync(oldToken, CancellationToken.None))).Status);
+ Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await CompleteAsync(host, oldToken)).Status);
+
+ var user = await host.ReloadUserAsync(userId);
+ Assert.False(user.EmailConfirmed);
+ Assert.True(user.PendingRegistration);
+
+ // The new address gets its own invite, and it must be confirmed again before finishing.
+ var newToken = host.Sent.LatestTokenFor(CorrectedEmail);
+ Assert.NotEqual(oldToken, newToken);
+ Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, newToken)).Status);
+ await host.ConfirmEmailAsync(newToken, CorrectedEmail);
+ Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status);
+ }
+
+ [Fact]
+ public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite()
+ {
+ await using var host = await TeamMemberInviteTestHost.CreateAsync();
+ var (userId, token) = await host.AddPendingMemberAsync(Email);
+ var emailsBefore = host.Sent.Messages.Count;
+
+ var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher");
+
+ Assert.True(updated.Success, updated.Error);
+ Assert.Equal(emailsBefore, host.Sent.Messages.Count);
+ var invite = Assert.Single(await host.InvitesAsync(userId));
+ Assert.Null(invite.RevokedAt);
+ Assert.Equal(TeamMemberRegistrationStatus.Ok,
+ (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status);
+ }
+
+ private static Task UpdateAsync(
+ TeamMemberInviteTestHost host,
+ string userId,
+ string email,
+ string role) =>
+ host.InScopeAsync(provider => provider.GetRequiredService().UpdateAsync(
+ userId,
+ new UpdateTeamMemberRequestDTO
+ {
+ Name = "Taylor Reed",
+ Role = role,
+ Color = "#0D9488",
+ Email = email,
+ Phone = "555-0100",
+ ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty()
+ },
+ TeamMemberInviteTestHost.Admin(),
+ CancellationToken.None));
+
+ private static Task CompleteAsync(TeamMemberInviteTestHost host, string token) =>
+ host.RegistrationAsync(service => service.CompleteAsync(
+ new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password },
+ CancellationToken.None));
+
+ private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) =>
+ host.InScopeAsync(provider => provider.GetRequiredService()
+ .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None));
+}