feat: inline unsubscribe button on email rows

- ✉✕ button appears on rows where the email has a List-Unsubscribe header
- One-click POST / HTTP GET executed automatically; shows ✓ on success
- mailto: targets open the user mail client (we never auto-send email)
- POST /email/{id}/unsubscribe: detects sender, then processes — UserId scoped
- EmailSummaryDto gains IsStarred, HasListUnsubscribe, SupportsOneClick fields
- Both SearchService and CleanupService updated to populate new DTO fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:22:28 +02:00
parent 80c2167b89
commit 2c9b402b08
7 changed files with 72 additions and 4 deletions
@@ -1,7 +1,9 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Api.Controllers;
@@ -15,7 +17,15 @@ namespace InboxIntel.Api.Controllers;
public class EmailController : ApiControllerBase
{
private readonly ICleanupService _cleanup;
public EmailController(ICleanupService cleanup) => _cleanup = cleanup;
private readonly IUnsubscribeService _unsub;
private readonly AppDbContext _db;
public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, AppDbContext db)
{
_cleanup = cleanup;
_unsub = unsub;
_db = db;
}
[HttpPost("{id:guid}/read")]
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
@@ -35,6 +45,32 @@ public class EmailController : ApiControllerBase
[HttpPost("{id:guid}/untrash")]
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
/// <summary>
/// Inline unsubscribe. Detects the unsubscribe mechanism for the email's sender,
/// then executes it (HTTP one-click or HTTP link). mailto targets cannot be sent
/// automatically — the mailto URI is returned so the user can action it themselves.
/// </summary>
[HttpPost("{id:guid}/unsubscribe")]
public async Task<IActionResult> Unsubscribe(Guid id, CancellationToken ct)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct);
if (email is null) return NotFound();
if (!email.HasListUnsubscribe) return BadRequest(new { error = "Email has no List-Unsubscribe header." });
await _unsub.DetectAsync(UserId, ct);
var item = await _db.UnsubscribeItems
.FirstOrDefaultAsync(u => u.UserId == UserId && u.SenderId == email.SenderId, ct);
if (item is null) return BadRequest(new { error = "No unsubscribe target found." });
if (item.Method == UnsubscribeMethod.MailTo)
return Ok(new { method = "mailto", target = item.UnsubscribeTarget });
var result = await _unsub.ProcessQueueAsync(UserId, new UnsubscribeRequestDto(new[] { item.Id }, Confirmed: true), ct);
return result.Succeeded ? Ok(new { method = "auto", succeeded = result.Value!.SucceededCount > 0 })
: BadRequest(new { error = result.Error });
}
private async Task<IActionResult> Act(Guid id, CleanupActionType action, CancellationToken ct)
{
var req = new CleanupRequestDto(action, new[] { id }, null, null, Confirmed: true);
+4 -1
View File
@@ -11,9 +11,12 @@ public record EmailSummaryDto(
string? SenderDisplayName,
DateTimeOffset SentAtUtc,
bool IsUnread,
bool IsStarred,
bool HasAttachments,
long SizeEstimateBytes,
EmailCategory Category);
EmailCategory Category,
bool HasListUnsubscribe,
bool SupportsOneClick);
public record SenderStatDto(
Guid SenderId,
@@ -36,7 +36,8 @@ public class CleanupService : ICleanupService
var emails = await ResolveTargetsAsync(userId, request, ct);
var sample = emails.Take(25).Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Sender!.Address, e.Sender.DisplayName,
e.SentAtUtc, e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category)).ToList();
e.SentAtUtc, e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)).ToList();
return new CleanupPreviewDto(request.Action, emails.Count, emails.Sum(e => e.SizeEstimateBytes), sample);
}
@@ -70,7 +70,8 @@ public class SearchService : ISearchService
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category))
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe))
.ToListAsync(ct);
return new PagedResult<EmailSummaryDto>