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:
@@ -105,6 +105,7 @@ export const EmailApi = {
|
|||||||
unstar: (id) => api.post(`/email/${id}/unstar`),
|
unstar: (id) => api.post(`/email/${id}/unstar`),
|
||||||
trash: (id) => api.post(`/email/${id}/trash`),
|
trash: (id) => api.post(`/email/${id}/trash`),
|
||||||
untrash: (id) => api.post(`/email/${id}/untrash`),
|
untrash: (id) => api.post(`/email/${id}/untrash`),
|
||||||
|
unsubscribe:(id) => api.post(`/email/${id}/unsubscribe`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ExportApi = {
|
export const ExportApi = {
|
||||||
|
|||||||
@@ -32,6 +32,22 @@ export default function EmailRow({ email: initial, onRemove }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUnsub = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (acting) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
const res = await EmailApi.unsubscribe(email.id);
|
||||||
|
if (res.method === 'mailto') {
|
||||||
|
window.location.href = res.target;
|
||||||
|
} else {
|
||||||
|
setEmail((prev) => ({ ...prev, _unsubDone: true }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTrash = async (e) => {
|
const handleTrash = async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (acting) return;
|
if (acting) return;
|
||||||
@@ -84,6 +100,14 @@ export default function EmailRow({ email: initial, onRemove }) {
|
|||||||
? act(EmailApi.unstar, { isStarred: false })
|
? act(EmailApi.unstar, { isStarred: false })
|
||||||
: act(EmailApi.star, { isStarred: true })}
|
: act(EmailApi.star, { isStarred: true })}
|
||||||
>⭐</button>
|
>⭐</button>
|
||||||
|
{email.hasListUnsubscribe && (
|
||||||
|
<button
|
||||||
|
className={`action-btn action-btn--unsub${email._unsubDone ? ' action-btn--done' : ''}`}
|
||||||
|
title={email._unsubDone ? 'Unsubscribed' : 'Unsubscribe'}
|
||||||
|
onClick={handleUnsub}
|
||||||
|
disabled={email._unsubDone}
|
||||||
|
>{email._unsubDone ? '✓' : '✉✕'}</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="action-btn action-btn--danger"
|
className="action-btn action-btn--danger"
|
||||||
title="Move to trash"
|
title="Move to trash"
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va
|
|||||||
.action-btn--danger:hover { color: var(--danger); }
|
.action-btn--danger:hover { color: var(--danger); }
|
||||||
.email-row:hover .action-btn { opacity: 0.6; }
|
.email-row:hover .action-btn { opacity: 0.6; }
|
||||||
.email-row--acting { opacity: 0.6; pointer-events: none; }
|
.email-row--acting { opacity: 0.6; pointer-events: none; }
|
||||||
|
.action-btn--unsub { font-size: 11px; }
|
||||||
|
.action-btn--done { opacity: 1 !important; color: var(--ok); }
|
||||||
|
|
||||||
.fv-sentinel { height: 1px; }
|
.fv-sentinel { height: 1px; }
|
||||||
.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
.fv-loading-more { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using InboxIntel.Application.Abstractions;
|
using InboxIntel.Application.Abstractions;
|
||||||
using InboxIntel.Application.DTOs;
|
using InboxIntel.Application.DTOs;
|
||||||
using InboxIntel.Domain.Enums;
|
using InboxIntel.Domain.Enums;
|
||||||
|
using InboxIntel.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace InboxIntel.Api.Controllers;
|
namespace InboxIntel.Api.Controllers;
|
||||||
|
|
||||||
@@ -15,7 +17,15 @@ namespace InboxIntel.Api.Controllers;
|
|||||||
public class EmailController : ApiControllerBase
|
public class EmailController : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ICleanupService _cleanup;
|
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")]
|
[HttpPost("{id:guid}/read")]
|
||||||
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
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")]
|
[HttpPost("{id:guid}/untrash")]
|
||||||
public Task<IActionResult> Untrash(Guid id, CancellationToken ct) => Act(id, CleanupActionType.Archive, ct);
|
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)
|
private async Task<IActionResult> Act(Guid id, CleanupActionType action, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var req = new CleanupRequestDto(action, new[] { id }, null, null, Confirmed: true);
|
var req = new CleanupRequestDto(action, new[] { id }, null, null, Confirmed: true);
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ public record EmailSummaryDto(
|
|||||||
string? SenderDisplayName,
|
string? SenderDisplayName,
|
||||||
DateTimeOffset SentAtUtc,
|
DateTimeOffset SentAtUtc,
|
||||||
bool IsUnread,
|
bool IsUnread,
|
||||||
|
bool IsStarred,
|
||||||
bool HasAttachments,
|
bool HasAttachments,
|
||||||
long SizeEstimateBytes,
|
long SizeEstimateBytes,
|
||||||
EmailCategory Category);
|
EmailCategory Category,
|
||||||
|
bool HasListUnsubscribe,
|
||||||
|
bool SupportsOneClick);
|
||||||
|
|
||||||
public record SenderStatDto(
|
public record SenderStatDto(
|
||||||
Guid SenderId,
|
Guid SenderId,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ public class CleanupService : ICleanupService
|
|||||||
var emails = await ResolveTargetsAsync(userId, request, ct);
|
var emails = await ResolveTargetsAsync(userId, request, ct);
|
||||||
var sample = emails.Take(25).Select(e => new EmailSummaryDto(
|
var sample = emails.Take(25).Select(e => new EmailSummaryDto(
|
||||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Sender!.Address, e.Sender.DisplayName,
|
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);
|
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(
|
.Select(e => new EmailSummaryDto(
|
||||||
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
|
||||||
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
|
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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return new PagedResult<EmailSummaryDto>
|
return new PagedResult<EmailSummaryDto>
|
||||||
|
|||||||
Reference in New Issue
Block a user