SES SNS Bounce Handling
This commit is contained in:
@@ -581,6 +581,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bounce History Modal -->
|
||||
<div class="modal" id="bounceHistoryModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="bounceHistoryTitle">Bounce History</h3>
|
||||
<button class="modal-close" id="bounceHistoryModalClose">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="member-info-banner" id="bounceHistoryMemberInfo">
|
||||
<div class="member-avatar">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="member-details">
|
||||
<h4 id="bounceHistoryMemberName">Member Name</h4>
|
||||
<p id="bounceHistoryMemberEmail">member@example.com</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bounce-summary">
|
||||
<div class="bounce-stat">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<div>
|
||||
<div class="bounce-stat-label">Total Bounces</div>
|
||||
<div class="bounce-stat-value" id="bounceTotalCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bounce-stat">
|
||||
<i class="fas fa-clock"></i>
|
||||
<div>
|
||||
<div class="bounce-stat-label">Last Bounce</div>
|
||||
<div class="bounce-stat-value" id="bounceLastDate">Never</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bounce-stat">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<div>
|
||||
<div class="bounce-stat-label">Status</div>
|
||||
<div class="bounce-stat-value" id="bounceStatusText">Clean</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bounce-history-section">
|
||||
<h5>Bounce Events</h5>
|
||||
<div class="bounce-history-list" id="bounceHistoryList">
|
||||
<!-- Dynamic content will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="bounceHistoryCloseBtn">Close</button>
|
||||
<button type="button" class="btn btn-warning" id="bounceHistoryResetBtn" data-requires-write>
|
||||
<i class="fas fa-redo"></i>
|
||||
Reset Bounce Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<div class="modal" id="confirmModal">
|
||||
<div class="modal-content">
|
||||
@@ -601,7 +662,7 @@
|
||||
</div>
|
||||
|
||||
<script src="static/js/api.js"></script>
|
||||
<script src="static/js/ui.js"></script>
|
||||
<script src="static/js/app.js"></script>
|
||||
<script src="static/js/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -624,6 +624,32 @@ body {
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
/* Bounce badges */
|
||||
.bounce-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bounce-badge.bounce-hard {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.bounce-badge.bounce-soft {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.bounce-badge.bounce-warning {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
@@ -1478,4 +1504,148 @@ body {
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
/* Bounce History Modal Styles */
|
||||
.bounce-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-6) 0;
|
||||
padding: var(--space-4);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.bounce-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.bounce-stat i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.bounce-stat-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.bounce-stat-value {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
|
||||
.bounce-stat-value.text-danger {
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
.bounce-stat-value.text-warning {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.bounce-stat-value.text-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.bounce-history-section {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.bounce-history-section h5 {
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.bounce-history-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.bounce-history-item {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.bounce-history-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bounce-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.bounce-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bounce-type.bounce-type-permanent {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.bounce-type.bounce-type-transient {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.bounce-type.bounce-type-undetermined {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-600);
|
||||
}
|
||||
|
||||
.bounce-date {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.bounce-subtype {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--gray-600);
|
||||
margin-top: var(--space-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bounce-diagnostic {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--gray-500);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--warning-color);
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user