SES SNS Bounce Handling

This commit is contained in:
James Pattinson
2025-10-13 15:05:42 +00:00
parent ac23638125
commit 72f3297a80
12 changed files with 1276 additions and 3 deletions

View File

@@ -254,6 +254,17 @@ class APIClient {
})
});
}
// Bounce management API
async getMemberBounces(memberId) {
return this.request(`/members/${memberId}/bounces`);
}
async resetBounceStatus(memberId) {
return this.request(`/members/${memberId}/bounce-status`, {
method: 'PATCH'
});
}
}
/**

View File

@@ -438,6 +438,7 @@ class MailingListApp {
row.innerHTML = `
<td>
<div class="font-medium">${uiManager.escapeHtml(member.name)}</div>
${member.bounce_count > 0 ? `<div class="text-xs text-muted" style="margin-top: 2px;"></div>` : ''}
</td>
<td>
<a href="mailto:${member.email}" style="color: var(--primary-color)">
@@ -458,6 +459,15 @@ class MailingListApp {
</td>
`;
// Add bounce badge if member has bounces
if (member.bounce_count > 0) {
const bounceInfoDiv = row.cells[0].querySelector('.text-xs');
const bounceBadge = uiManager.createBounceStatusBadge(member.bounce_status, member.bounce_count);
if (bounceBadge) {
bounceInfoDiv.appendChild(bounceBadge);
}
}
// Add status badge
const statusCell = row.cells[3];
statusCell.appendChild(uiManager.createStatusBadge(member.active));
@@ -474,6 +484,18 @@ class MailingListApp {
uiManager.showMemberSubscriptionsModal(member);
});
// Create Bounces button (show if member has any bounces or for admins/operators)
if (member.bounce_count > 0 || hasWriteAccess) {
const bouncesBtn = document.createElement('button');
bouncesBtn.className = `btn btn-sm ${member.bounce_count > 0 ? 'btn-warning' : 'btn-secondary'}`;
bouncesBtn.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Bounces${member.bounce_count > 0 ? ` (${member.bounce_count})` : ''}`;
bouncesBtn.title = 'View Bounce History';
bouncesBtn.addEventListener('click', () => {
uiManager.showBounceHistoryModal(member);
});
actionsCell.appendChild(bouncesBtn);
}
// Only show edit/delete buttons for users with write access
if (hasWriteAccess) {
const editBtn = uiManager.createActionButton('Edit', 'edit', 'btn-secondary', () => {

View File

@@ -76,6 +76,19 @@ class UIManager {
this.handleMemberSubscriptionsSave();
});
// Bounce history modal
document.getElementById('bounceHistoryModalClose').addEventListener('click', () => {
this.closeModal(document.getElementById('bounceHistoryModal'));
});
document.getElementById('bounceHistoryCloseBtn').addEventListener('click', () => {
this.closeModal(document.getElementById('bounceHistoryModal'));
});
document.getElementById('bounceHistoryResetBtn').addEventListener('click', () => {
this.handleBounceStatusReset();
});
// Form submissions
document.getElementById('listForm').addEventListener('submit', (e) => {
e.preventDefault();
@@ -110,6 +123,19 @@ class UIManager {
this.confirmCallback = null;
this.closeModal(document.getElementById('confirmModal'));
});
// Bounce history modal
document.getElementById('bounceHistoryModalClose').addEventListener('click', () => {
this.closeModal(document.getElementById('bounceHistoryModal'));
});
document.getElementById('bounceHistoryCloseBtn').addEventListener('click', () => {
this.closeModal(document.getElementById('bounceHistoryModal'));
});
document.getElementById('bounceHistoryResetBtn').addEventListener('click', () => {
this.handleResetBounceStatus();
});
}
/**
@@ -664,6 +690,117 @@ class UIManager {
}
}
/**
* Show bounce history modal for a member
*/
async showBounceHistoryModal(member) {
const modal = document.getElementById('bounceHistoryModal');
// Update member info
document.getElementById('bounceHistoryTitle').textContent = `Bounce History - ${member.name}`;
document.getElementById('bounceHistoryMemberName').textContent = member.name;
document.getElementById('bounceHistoryMemberEmail').textContent = member.email;
// Update summary stats
document.getElementById('bounceTotalCount').textContent = member.bounce_count || 0;
document.getElementById('bounceLastDate').textContent = this.formatDateTime(member.last_bounce_at);
const statusText = document.getElementById('bounceStatusText');
statusText.className = 'bounce-stat-value';
if (member.bounce_status === 'hard_bounce') {
statusText.textContent = 'Hard Bounce';
statusText.classList.add('text-danger');
} else if (member.bounce_status === 'soft_bounce') {
statusText.textContent = 'Soft Bounce';
statusText.classList.add('text-warning');
} else {
statusText.textContent = 'Clean';
statusText.classList.add('text-success');
}
try {
this.currentMemberForBounces = member;
// Load bounce history
const bounces = await apiClient.getMemberBounces(member.member_id);
this.renderBounceHistory(bounces);
this.showModal(modal);
} catch (error) {
this.handleError(error, 'Failed to load bounce history');
}
}
/**
* Render bounce history list
*/
renderBounceHistory(bounces) {
const container = document.getElementById('bounceHistoryList');
container.innerHTML = '';
if (bounces.length === 0) {
container.innerHTML = `
<div class="empty-state">
<i class="fas fa-check-circle"></i>
<p>No bounces recorded for this member</p>
</div>
`;
return;
}
bounces.forEach(bounce => {
const item = document.createElement('div');
item.className = 'bounce-history-item';
let typeClass = 'bounce-type-';
if (bounce.bounce_type === 'Permanent') {
typeClass += 'permanent';
} else if (bounce.bounce_type === 'Transient') {
typeClass += 'transient';
} else {
typeClass += 'undetermined';
}
item.innerHTML = `
<div class="bounce-header">
<span class="bounce-type ${typeClass}">
<i class="fas fa-${bounce.bounce_type === 'Permanent' ? 'times-circle' : bounce.bounce_type === 'Transient' ? 'exclamation-circle' : 'question-circle'}"></i>
${bounce.bounce_type}
</span>
<span class="bounce-date">${this.formatDateTime(bounce.timestamp)}</span>
</div>
${bounce.bounce_subtype ? `<div class="bounce-subtype">Subtype: ${this.escapeHtml(bounce.bounce_subtype)}</div>` : ''}
${bounce.diagnostic_code ? `<div class="bounce-diagnostic">${this.escapeHtml(bounce.diagnostic_code)}</div>` : ''}
`;
container.appendChild(item);
});
}
/**
* Handle bounce status reset
*/
async handleResetBounceStatus() {
if (!this.currentMemberForBounces) return;
this.showConfirmation(
`Are you sure you want to reset the bounce status for "${this.currentMemberForBounces.name}"? This will clear the bounce count and allow emails to be sent to this address again.`,
async () => {
try {
this.setLoading(true);
await apiClient.resetBounceStatus(this.currentMemberForBounces.member_id);
this.showNotification('Bounce status reset successfully', 'success');
this.closeModal(document.getElementById('bounceHistoryModal'));
await window.app.loadData();
} catch (error) {
this.handleError(error, 'Failed to reset bounce status');
} finally {
this.setLoading(false);
}
}
);
}
/**
* Handle API errors
*/
@@ -715,6 +852,49 @@ class UIManager {
return badge;
}
/**
* Create bounce status badge
*/
createBounceStatusBadge(bounceStatus, bounceCount) {
const badge = document.createElement('span');
if (bounceStatus === 'hard_bounce') {
badge.className = 'bounce-badge bounce-hard';
badge.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Hard Bounce`;
badge.title = `${bounceCount} bounce(s) - Email permanently failed`;
} else if (bounceStatus === 'soft_bounce') {
badge.className = 'bounce-badge bounce-soft';
badge.innerHTML = `<i class="fas fa-exclamation-circle"></i> Soft Bounce`;
badge.title = `${bounceCount} bounce(s) - Temporary delivery issues`;
} else if (bounceCount > 0) {
badge.className = 'bounce-badge bounce-warning';
badge.innerHTML = `<i class="fas fa-info-circle"></i> ${bounceCount} bounce(s)`;
badge.title = `${bounceCount} bounce(s) recorded`;
} else {
return null; // No badge for clean status
}
return badge;
}
/**
* Format date and time
*/
formatDateTime(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleString();
}
/**
* Format date only
*/
formatDate(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString();
}
/**
* Format email as mailto link
*/