Merge pull request #119 from christianlouis/copilot/check-empty-emails-and-imap-errors
fix: drop empty emails, clear stale IMAP errors on success, harden IMAP RFC822 extraction
This commit is contained in:
@@ -27,6 +27,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
sequence numbers, followed by a lightweight `FETCH (UID)` to resolve them
|
sequence numbers, followed by a lightweight `FETCH (UID)` to resolve them
|
||||||
to stable UIDs; all subsequent operations (`FETCH`, `STORE`) continue to
|
to stable UIDs; all subsequent operations (`FETCH`, `STORE`) continue to
|
||||||
use the UID form.
|
use the UID form.
|
||||||
|
- **IMAP: filter whitespace-only RFC822 responses** — the FETCH extraction
|
||||||
|
loop now checks `email_data.strip()` so that trivially-empty byte payloads
|
||||||
|
(e.g. `\r\n`) returned by servers like T-Online are rejected at the
|
||||||
|
extraction layer rather than being forwarded as empty emails.
|
||||||
|
- **Tasks: drop completely empty emails with a warning** — after parsing
|
||||||
|
each fetched email, the processing loop now checks whether the message has
|
||||||
|
no subject, no From header, and no body. Such emails are silently dropped
|
||||||
|
(logged as `WARNING`, written to the processing log, UID recorded as seen
|
||||||
|
so the message is not retried). This handles cases where T-Online or
|
||||||
|
other servers emit genuinely empty RFC822 responses that may indicate a
|
||||||
|
server-side bug.
|
||||||
|
- **Status page: clear stale IMAP errors after a successful run** — on a
|
||||||
|
successful processing run (`emails_failed == 0`), the account's
|
||||||
|
`last_error_message` and `last_error_at` fields are now cleared.
|
||||||
|
Previously a transient IMAP error would remain visible on the dashboard
|
||||||
|
even after subsequent pulls completed without problems.
|
||||||
|
- **Dashboard: fix UTC hour-shift on "Last check" timestamps** — the dashboard
|
||||||
|
page was using `new Date(iso)` which treats timezone-naive ISO strings from
|
||||||
|
the backend as local time, shifting relative labels (e.g. "1h ago" instead
|
||||||
|
of "Just now") for users outside UTC. The dashboard now uses the same
|
||||||
|
`parseUTC()` helper that the logs page already applied. The helper functions
|
||||||
|
`formatRelative`, `formatDate`, and `formatDuration` have been consolidated
|
||||||
|
into `src/lib/date-utils.ts` and are imported by all pages.
|
||||||
|
|
||||||
## v0.4.2 (2026-03-28)
|
## v0.4.2 (2026-03-28)
|
||||||
|
|
||||||
|
|||||||
@@ -396,14 +396,17 @@ class MailProcessor:
|
|||||||
email_data = line
|
email_data = line
|
||||||
break
|
break
|
||||||
|
|
||||||
if email_data:
|
if email_data and email_data.strip():
|
||||||
emails.append(email_data)
|
emails.append(email_data)
|
||||||
new_uids.append(uid_str)
|
new_uids.append(uid_str)
|
||||||
fetched_uids.append(uid)
|
fetched_uids.append(uid)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"No email data extracted for UID {uid_str} on "
|
f"No email data extracted for UID {uid_str} on "
|
||||||
f"account {self.account.id}"
|
f"account {self.account.id} "
|
||||||
|
f"(raw bytes: {len(email_data) if email_data else 0}); "
|
||||||
|
"this may indicate a server-side error producing empty "
|
||||||
|
"IMAP RFC822 responses"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ async def process_mail_account(account_id: int):
|
|||||||
return
|
return
|
||||||
|
|
||||||
successfully_forwarded_uids: list[str] = []
|
successfully_forwarded_uids: list[str] = []
|
||||||
|
skipped_empty_uids: list[str] = []
|
||||||
|
|
||||||
if len(emails) != len(new_uids):
|
if len(emails) != len(new_uids):
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -238,6 +239,55 @@ async def process_mail_account(account_id: int):
|
|||||||
forwarded_ok = False
|
forwarded_ok = False
|
||||||
error_msg: str | None = None
|
error_msg: str | None = None
|
||||||
|
|
||||||
|
# ── Detect completely empty emails ───────────────────────────
|
||||||
|
# T-Online (and potentially other servers) may return empty
|
||||||
|
# RFC822 responses. An email with no subject, no sender and no
|
||||||
|
# body provides no value and should not be forwarded. We log a
|
||||||
|
# warning (it may indicate a server-side bug) and skip the
|
||||||
|
# message while still recording its UID so it is not retried.
|
||||||
|
if not email_subject and not email_from:
|
||||||
|
body_has_content = False
|
||||||
|
try:
|
||||||
|
if msg.is_multipart(): # type: ignore[possibly-undefined]
|
||||||
|
for part in msg.walk():
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if isinstance(payload, bytes) and payload.strip():
|
||||||
|
body_has_content = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True) # type: ignore[possibly-undefined]
|
||||||
|
body_has_content = isinstance(payload, bytes) and bool(
|
||||||
|
payload.strip()
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not body_has_content:
|
||||||
|
logger.warning(
|
||||||
|
"Dropping completely empty email (uid=%s, account=%s, "
|
||||||
|
"size=%d bytes) — no subject, sender, or body; "
|
||||||
|
"this may indicate a server-side error.",
|
||||||
|
uid,
|
||||||
|
account.id,
|
||||||
|
email_size_bytes,
|
||||||
|
)
|
||||||
|
skipped_empty_uids.append(uid)
|
||||||
|
db.add(
|
||||||
|
ProcessingLog(
|
||||||
|
user_id=account.user_id,
|
||||||
|
mail_account_id=account.id,
|
||||||
|
processing_run_id=run.id,
|
||||||
|
level="WARNING",
|
||||||
|
message="Dropped empty email (no subject, sender, or body)",
|
||||||
|
email_subject=None,
|
||||||
|
email_from=None,
|
||||||
|
email_size_bytes=email_size_bytes,
|
||||||
|
success=False,
|
||||||
|
error_details={"reason": "empty_email"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if use_gmail_api and gmail_service and gmail_cred:
|
if use_gmail_api and gmail_service and gmail_cred:
|
||||||
# Inject via Gmail API (preferred)
|
# Inject via Gmail API (preferred)
|
||||||
@@ -331,6 +381,17 @@ async def process_mail_account(account_id: int):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Also persist UIDs of dropped empty emails so they are not
|
||||||
|
# re-fetched and re-evaluated on the next run.
|
||||||
|
for uid in skipped_empty_uids:
|
||||||
|
if uid not in already_seen_uids:
|
||||||
|
db.add(
|
||||||
|
DownloadedMessageId(
|
||||||
|
mail_account_id=account.id,
|
||||||
|
message_uid=uid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# If Gmail API was used, persist any refreshed access token back to
|
# If Gmail API was used, persist any refreshed access token back to
|
||||||
# the DB so the next run doesn't need an extra token-refresh call.
|
# the DB so the next run doesn't need an extra token-refresh call.
|
||||||
if use_gmail_api and gmail_service and gmail_cred:
|
if use_gmail_api and gmail_service and gmail_cred:
|
||||||
@@ -361,6 +422,8 @@ async def process_mail_account(account_id: int):
|
|||||||
if emails_failed == 0:
|
if emails_failed == 0:
|
||||||
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_successful_check_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
account.status = AccountStatus.ACTIVE # type: ignore[assignment]
|
||||||
|
account.last_error_message = None # type: ignore[assignment]
|
||||||
|
account.last_error_at = None # type: ignore[assignment]
|
||||||
else:
|
else:
|
||||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for empty-email detection in the task processing pipeline.
|
||||||
|
|
||||||
|
Covers two layers:
|
||||||
|
1. tasks._empty_email_check_logic – the inline body-inspection that
|
||||||
|
decides whether a parsed email should be dropped.
|
||||||
|
2. The "clear last_error_message on success" contract – verifying that a
|
||||||
|
successful run nulls out any previously stored error so the status page
|
||||||
|
no longer shows stale IMAP errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import email as email_lib
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(raw: bytes):
|
||||||
|
"""Parse raw bytes into an email.Message object (same call used in tasks.py)."""
|
||||||
|
return email_lib.message_from_bytes(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _body_has_content(msg) -> bool:
|
||||||
|
"""
|
||||||
|
Mirror of the body-detection logic in tasks.py's email processing loop.
|
||||||
|
|
||||||
|
Returns True if the message has at least one non-empty / non-whitespace
|
||||||
|
text or binary payload.
|
||||||
|
"""
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
payload = part.get_payload(decode=True)
|
||||||
|
if isinstance(payload, bytes) and payload.strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
payload = msg.get_payload(decode=True)
|
||||||
|
return isinstance(payload, bytes) and bool(payload.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_empty_email(raw: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
Replicates the complete empty-email gate from tasks.py:
|
||||||
|
- no subject AND no from AND no body → True (should be dropped)
|
||||||
|
"""
|
||||||
|
msg = _parse(raw)
|
||||||
|
email_subject = (msg.get("Subject", "") or "").strip() or None
|
||||||
|
email_from = (msg.get("From", "") or "").strip() or None
|
||||||
|
|
||||||
|
if email_subject or email_from:
|
||||||
|
return False # Has at least a header → not empty
|
||||||
|
|
||||||
|
return not _body_has_content(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Body-detection tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBodyHasContent:
|
||||||
|
def test_plain_text_body(self):
|
||||||
|
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\nHello world"
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is True
|
||||||
|
|
||||||
|
def test_empty_body(self):
|
||||||
|
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n"
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is False
|
||||||
|
|
||||||
|
def test_crlf_only_body(self):
|
||||||
|
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n\r\n"
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is False
|
||||||
|
|
||||||
|
def test_whitespace_only_body(self):
|
||||||
|
raw = b"From: a@b.com\r\nSubject: Hi\r\n\r\n \t "
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is False
|
||||||
|
|
||||||
|
def test_multipart_with_content(self):
|
||||||
|
raw = (
|
||||||
|
b"MIME-Version: 1.0\r\n"
|
||||||
|
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
|
||||||
|
b"--X\r\n"
|
||||||
|
b"Content-Type: text/plain\r\n\r\n"
|
||||||
|
b"Hello from multipart\r\n"
|
||||||
|
b"--X--\r\n"
|
||||||
|
)
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is True
|
||||||
|
|
||||||
|
def test_multipart_all_empty_parts(self):
|
||||||
|
raw = (
|
||||||
|
b"MIME-Version: 1.0\r\n"
|
||||||
|
b"Content-Type: multipart/mixed; boundary=X\r\n\r\n"
|
||||||
|
b"--X\r\n"
|
||||||
|
b"Content-Type: text/plain\r\n\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
b"--X--\r\n"
|
||||||
|
)
|
||||||
|
msg = _parse(raw)
|
||||||
|
assert _body_has_content(msg) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Complete empty-email gate tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsEmptyEmail:
|
||||||
|
def test_completely_empty_raw_bytes(self):
|
||||||
|
assert _is_empty_email(b"") is True
|
||||||
|
|
||||||
|
def test_only_headers_no_body(self):
|
||||||
|
raw = b"\r\n"
|
||||||
|
assert _is_empty_email(raw) is True
|
||||||
|
|
||||||
|
def test_no_subject_no_from_no_body(self):
|
||||||
|
raw = b"Date: Mon, 1 Jan 2024 00:00:00 +0000\r\n\r\n"
|
||||||
|
assert _is_empty_email(raw) is True
|
||||||
|
|
||||||
|
def test_has_subject_no_from_no_body(self):
|
||||||
|
"""Subject alone is enough to keep the email."""
|
||||||
|
raw = b"Subject: Alert\r\n\r\n"
|
||||||
|
assert _is_empty_email(raw) is False
|
||||||
|
|
||||||
|
def test_has_from_no_subject_no_body(self):
|
||||||
|
"""From alone is enough to keep the email."""
|
||||||
|
raw = b"From: sender@example.com\r\n\r\n"
|
||||||
|
assert _is_empty_email(raw) is False
|
||||||
|
|
||||||
|
def test_no_headers_but_body_has_content(self):
|
||||||
|
"""Body content alone is enough to keep the email."""
|
||||||
|
raw = b"\r\nThis is the body."
|
||||||
|
assert _is_empty_email(raw) is False
|
||||||
|
|
||||||
|
def test_normal_email_is_not_empty(self):
|
||||||
|
raw = (
|
||||||
|
b"From: sender@example.com\r\n"
|
||||||
|
b"Subject: Hello\r\n\r\n"
|
||||||
|
b"Some body text.\r\n"
|
||||||
|
)
|
||||||
|
assert _is_empty_email(raw) is False
|
||||||
|
|
||||||
|
def test_crlf_only_is_empty(self):
|
||||||
|
assert _is_empty_email(b"\r\n\r\n") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status-clearing contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLastErrorMessageClearOnSuccess:
|
||||||
|
"""
|
||||||
|
The `last_error_message` field must be cleared when a run completes with
|
||||||
|
zero forwarding failures, so the status page does not show stale errors.
|
||||||
|
|
||||||
|
This test exercises the same branch logic used in tasks.py without
|
||||||
|
needing a full Celery / DB setup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _simulate_account_status_update(
|
||||||
|
self, emails_failed: int, current_error_message: str | None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Simulate the account-status block from tasks.py:
|
||||||
|
|
||||||
|
if emails_failed == 0:
|
||||||
|
account.status = ACTIVE
|
||||||
|
account.last_error_message = None
|
||||||
|
account.last_error_at = None
|
||||||
|
else:
|
||||||
|
account.status = ERROR
|
||||||
|
account.last_error_at = <now>
|
||||||
|
account.last_error_message = f"{emails_failed} emails failed to forward"
|
||||||
|
|
||||||
|
Returns a dict with the resulting field values.
|
||||||
|
"""
|
||||||
|
from app.models.database_models import AccountStatus
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"last_error_message": current_error_message,
|
||||||
|
"last_error_at": "2024-01-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
if emails_failed == 0:
|
||||||
|
state["status"] = AccountStatus.ACTIVE
|
||||||
|
state["last_error_message"] = None
|
||||||
|
state["last_error_at"] = None
|
||||||
|
else:
|
||||||
|
state["status"] = AccountStatus.ERROR
|
||||||
|
state["last_error_message"] = f"{emails_failed} emails failed to forward"
|
||||||
|
state["last_error_at"] = "2024-01-02"
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_error_cleared_on_zero_failures(self):
|
||||||
|
"""A previously stored error is wiped when all emails forward OK."""
|
||||||
|
result = self._simulate_account_status_update(
|
||||||
|
emails_failed=0,
|
||||||
|
current_error_message="IMAP fetch error: UID only possible with …",
|
||||||
|
)
|
||||||
|
assert result["last_error_message"] is None
|
||||||
|
assert result["last_error_at"] is None
|
||||||
|
|
||||||
|
def test_error_set_when_failures_exist(self):
|
||||||
|
"""When emails fail, the error message is updated, not cleared."""
|
||||||
|
result = self._simulate_account_status_update(
|
||||||
|
emails_failed=3,
|
||||||
|
current_error_message=None,
|
||||||
|
)
|
||||||
|
assert result["last_error_message"] == "3 emails failed to forward"
|
||||||
|
assert result["last_error_at"] is not None
|
||||||
|
|
||||||
|
def test_no_error_remains_none_on_success(self):
|
||||||
|
"""A clean account stays clean after a successful run."""
|
||||||
|
result = self._simulate_account_status_update(
|
||||||
|
emails_failed=0,
|
||||||
|
current_error_message=None,
|
||||||
|
)
|
||||||
|
assert result["last_error_message"] is None
|
||||||
@@ -422,3 +422,60 @@ class TestFetchImapEmailsUidCommands:
|
|||||||
) as mock_cls:
|
) as mock_cls:
|
||||||
await processor._fetch_imap_emails(10, set())
|
await processor._fetch_imap_emails(10, set())
|
||||||
mock_cls.assert_called_once()
|
mock_cls.assert_called_once()
|
||||||
|
|
||||||
|
async def test_whitespace_only_rfc822_body_is_skipped(self, processor, mock_imap):
|
||||||
|
"""A UID FETCH that returns only whitespace bytes must not be added to results.
|
||||||
|
|
||||||
|
T-Online (and potentially other servers) can return RFC822 responses
|
||||||
|
whose body is just CR LF or other whitespace. The extraction loop
|
||||||
|
must treat these as absent email data, not as a valid message.
|
||||||
|
"""
|
||||||
|
# Simulate a server that returns b"\r\n" instead of real email bytes
|
||||||
|
whitespace_response = _make_imap_response(
|
||||||
|
result="OK",
|
||||||
|
lines=[b"1 (UID 99 RFC822 {2}", b"\r\n", b")"],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"99"])
|
||||||
|
mock_imap.fetch.return_value = _make_uid_list_response([("99", "99")])
|
||||||
|
|
||||||
|
mock_imap.uid = AsyncMock(
|
||||||
|
side_effect=lambda cmd, *args: (
|
||||||
|
whitespace_response if cmd == "fetch" else _make_imap_response()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||||
|
return_value=mock_imap,
|
||||||
|
):
|
||||||
|
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||||
|
|
||||||
|
# Whitespace-only response must not produce a message
|
||||||
|
assert emails == []
|
||||||
|
assert new_uids == []
|
||||||
|
|
||||||
|
async def test_empty_bytes_rfc822_body_is_skipped(self, processor, mock_imap):
|
||||||
|
"""A UID FETCH that returns b'' as the body must not be added to results."""
|
||||||
|
empty_response = _make_imap_response(
|
||||||
|
result="OK",
|
||||||
|
lines=[b"1 (UID 77 RFC822 {0}", b"", b")"],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_imap.search.return_value = _make_imap_response(result="OK", lines=[b"77"])
|
||||||
|
mock_imap.fetch.return_value = _make_uid_list_response([("77", "77")])
|
||||||
|
|
||||||
|
mock_imap.uid = AsyncMock(
|
||||||
|
side_effect=lambda cmd, *args: (
|
||||||
|
empty_response if cmd == "fetch" else _make_imap_response()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.services.mail_processor.aioimaplib.IMAP4_SSL",
|
||||||
|
return_value=mock_imap,
|
||||||
|
):
|
||||||
|
emails, new_uids = await processor._fetch_imap_emails(10, set())
|
||||||
|
|
||||||
|
assert emails == []
|
||||||
|
assert new_uids == []
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/authStore';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { adminApi, AdminProcessingRun } from '@/lib/api';
|
import { adminApi, AdminProcessingRun } from '@/lib/api';
|
||||||
import { parseUTC } from '@/lib/date-utils';
|
import { formatDate, formatDuration } from '@/lib/date-utils';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -25,19 +25,6 @@ const STATUS_STYLES: Record<string, string> = {
|
|||||||
running: 'bg-blue-100 text-blue-800',
|
running: 'bg-blue-100 text-blue-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDuration(seconds?: number | null): string {
|
|
||||||
if (seconds == null) return '—';
|
|
||||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
||||||
return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return parseUTC(iso).toLocaleString(undefined, {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'medium',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminLogsPage() {
|
export default function AdminLogsPage() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
|||||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
import { mailAccountsApi, MailAccount } from '@/lib/api';
|
||||||
|
import { formatRelative } from '@/lib/date-utils';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
Mail,
|
Mail,
|
||||||
@@ -16,17 +17,6 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function formatRelative(iso?: string | null): string {
|
|
||||||
if (!iso) return 'Never';
|
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
if (minutes < 1) return 'Just now';
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AuthGuard } from '@/components/AuthGuard';
|
|||||||
import { DashboardLayout } from '@/components/DashboardLayout';
|
import { DashboardLayout } from '@/components/DashboardLayout';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
|
import { processingRunsApi, mailAccountsApi, MailAccount, ProcessingRun, ProcessingLog } from '@/lib/api';
|
||||||
import { parseUTC } from '@/lib/date-utils';
|
import { formatRelative, formatDate, formatDuration } from '@/lib/date-utils';
|
||||||
import {
|
import {
|
||||||
Inbox,
|
Inbox,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -19,31 +19,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
function formatRelative(iso?: string | null): string {
|
|
||||||
if (!iso) return 'Never';
|
|
||||||
const diff = Date.now() - parseUTC(iso).getTime();
|
|
||||||
const minutes = Math.floor(diff / 60000);
|
|
||||||
if (minutes < 1) return 'Just now';
|
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return `${hours}h ago`;
|
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return parseUTC(iso).toLocaleString(undefined, {
|
|
||||||
dateStyle: 'short',
|
|
||||||
timeStyle: 'medium',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(seconds?: number | null): string {
|
|
||||||
if (seconds == null) return '—';
|
|
||||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
|
||||||
const totalSecs = Math.floor(seconds);
|
|
||||||
return `${Math.floor(totalSecs / 60)}m ${totalSecs % 60}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RunDetailRow({ run }: { run: ProcessingRun }) {
|
function RunDetailRow({ run }: { run: ProcessingRun }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [logsPage, setLogsPage] = useState(1);
|
const [logsPage, setLogsPage] = useState(1);
|
||||||
|
|||||||
@@ -14,3 +14,40 @@
|
|||||||
export function parseUTC(iso: string): Date {
|
export function parseUTC(iso: string): Date {
|
||||||
return new Date(/Z|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
return new Date(/Z|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO-8601 UTC timestamp as a human-readable relative time string
|
||||||
|
* (e.g. "3m ago", "2h ago", "1d ago"). Returns "Never" for falsy input.
|
||||||
|
*/
|
||||||
|
export function formatRelative(iso?: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const diff = Date.now() - parseUTC(iso).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO-8601 UTC timestamp as a locale-aware short date + medium time
|
||||||
|
* string, e.g. "28.03.26, 22:19:44".
|
||||||
|
*/
|
||||||
|
export function formatDate(iso: string): string {
|
||||||
|
return parseUTC(iso).toLocaleString(undefined, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration in seconds as a human-readable string,
|
||||||
|
* e.g. "3.1s" or "2m 5s". Returns "—" for null/undefined input.
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds?: number | null): string {
|
||||||
|
if (seconds == null) return '—';
|
||||||
|
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||||
|
const totalSecs = Math.floor(seconds);
|
||||||
|
return `${Math.floor(totalSecs / 60)}m ${totalSecs % 60}s`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user