Skip to content

feat: suggest subscriptions from email via IMAP - #83

Merged
nazozokc merged 8 commits into
mainfrom
AI-agent
Jul 26, 2026
Merged

feat: suggest subscriptions from email via IMAP#83
nazozokc merged 8 commits into
mainfrom
AI-agent

Conversation

@nazozokc

@nazozokc nazozokc commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

Suggest subscriptions by scanning email inbox via IMAP. Scans bank/credit-card/receipt/wallet payment emails and generates suggestions for review.

What was done

  • IMAP integration: Connect to any IMAP server via imapflow, search recent inbox emails (last 7 days, max 50)
  • Email parsers: Bank (SMBC/Mizuho/Rakuten/Yucho), credit card (Rakuten/SMBC/JCB/AMEX), receipt (Netflix/Spotify/Apple/Google/Adobe/Microsoft/Notion/Slack/GitHub/Figma), wallet (PayPal/Apple Pay/Google Pay/Stripe), generic fallback
  • Interactive review: subtrack suggest launches inquirer flow to review candidates (add/edit/skip/quit) with duplicate detection
  • Notification banner: Compact 1-liner at top of list/summary/payment/upcoming showing pending suggestion count and upcoming payments
  • Auto-scan: Display commands trigger auto-scan with 1-hour cooldown (dynamic import so no startup cost without IMAP)
  • Config: IMAP settings via subtrack config set imapHost/imapPort/imapTls/imapUsername, password via SUBTRACK_IMAP_PASSWORD env var
  • Bug fixes: suggest list --json when empty, regex escaping in generic parser, subscription/add.ts type narrowing
  • Dependencies: Added imapflow (MIT)

Testing

  • pnpm test: 396/397 pass (1 pre-existing notify failure)
  • tsc --noEmit: 0 errors
  • pnpm build: 496 kB, 18 chunks
  • Manual verification on all suggest subcommands, banner display, config

Commands

Command Description
subtrack suggest Interactive review
subtrack suggest list [--all] [--json] List suggestions
subtrack suggest view <id> View suggestion details
subtrack suggest add <id> Accept as subscription
subtrack suggest dismiss [id|--all] Dismiss suggestion(s)
subtrack suggest scan Force a scan (requires IMAP config)

Summary by CodeRabbit

  • New Features
    • Added email-based subscription suggestions with IMAP scanning, email parsing, and interactive review (list/view/add/dismiss, plus forced rescan).
    • Added the suggest command for suggestion management.
    • Added IMAP configuration support and notification banners for pending suggestions and upcoming payments.
    • Expanded MCP tooling for subscription management, exports, forecasting/period comparison, history, analytics, and bulk actions with input validation and rate limiting.
  • Improvements
    • Refreshed reporting period date-range handling and consolidated payment/subtotal calculations.
    • Standardized file-size formatting across commands and improved restore size display.
  • Tests
    • Made notification behavior time-deterministic; added Vitest timeout configuration.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@nazozokc, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12eb28c8-df98-43ae-8e6f-f5592975dadf

📥 Commits

Reviewing files that changed from the base of the PR and between e26c770 and 384495e.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (1)
  • pnpm-workspace.yaml
📝 Walkthrough

Walkthrough

Adds email-based subscription suggestions with IMAP scanning, parsing, persistence, review, and CLI commands. Splits the MCP server into modular handlers, tools, security, and transport modules. Also centralizes formatting/date helpers and reorganizes subscription workflows.

Changes

Email subscription suggestions

Layer / File(s) Summary
Suggestion contracts, configuration, and persistence
apps/subtrack/src/suggest/types.ts, apps/subtrack/src/types.ts, apps/subtrack/src/config.ts, apps/subtrack/src/db/*, apps/subtrack/package.json
Adds suggestion and IMAP types, IMAP config keys, the suggestions table, lifecycle database operations, and the imapflow dependency.
Email acquisition and parser pipeline
apps/subtrack/src/suggest/email-parser.ts, apps/subtrack/src/suggest/imap.ts, apps/subtrack/src/suggest/parser/*
Parses EML/plain text, retrieves recent IMAP messages, and produces candidates through provider-specific and generic parsers.
Scanning, review, and CLI integration
apps/subtrack/src/suggest/scan.ts, apps/subtrack/src/suggest/interactor.ts, apps/subtrack/src/suggest/suggest.ts, apps/subtrack/src/commands/suggest.ts, apps/subtrack/src/notifications/banner.ts
Adds cooldown-aware scanning, interactive matching and review, suggestion lifecycle commands, and notification banners in non-JSON flows.

MCP server modularization

Layer / File(s) Summary
MCP types and tool declarations
apps/subtrack/src/mcp/types.ts, apps/subtrack/src/mcp/tools.ts
Defines MCP data shapes and tool descriptors for subscription, export, analytics, forecast, comparison, bulk, and trial operations.
MCP tool handlers and dispatch
apps/subtrack/src/mcp/handlers.ts
Implements JSON-producing handlers and maps tool names to handler functions.
MCP request validation and transport
apps/subtrack/src/mcp/security.ts, apps/subtrack/src/mcp/server.ts, apps/subtrack/src/mcp/index.ts, apps/subtrack/src/mcp.ts
Adds rate limiting, request validation, tool dispatch, stdio startup, and compatibility re-exports.

CLI utilities and workflow refactors

Layer / File(s) Summary
Shared formatting and period calculations
apps/subtrack/src/format.ts, apps/subtrack/src/{backup,cleanup,maintenance,stats}.ts, apps/subtrack/src/date-utils.ts
Centralizes byte/file-size formatting and billing-period range calculations.
Payment total helpers and integrations
apps/subtrack/src/payment.ts, apps/subtrack/src/compare.ts, apps/subtrack/src/usage-total.ts
Adds reusable subtotal calculations and updates consumers to use centralized date and payment helpers.
Subscription command modules
apps/subtrack/src/subscription.ts, apps/subtrack/src/subscription/*
Separates add, edit, list, delete, clone, tag, archive, and unarchive workflows into dedicated modules while preserving barrel exports.
Validation and test support
apps/subtrack/src/__tests__/untested-commands.test.ts, apps/subtrack/vitest.config.ts, .github/workflows/dependency-review.yml, pnpm-workspace.yaml
Makes the upcoming-bills test time-deterministic, increases the Vitest timeout, and updates dependency review policy and overrides.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: suggesting subscriptions from email via IMAP.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch AI-agent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/subtrack/src/suggest/parser/bank.ts Fixed
Comment thread apps/subtrack/src/suggest/parser/credit-card.ts Fixed
Comment thread apps/subtrack/src/suggest/parser/wallet.ts Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (9)
apps/subtrack/src/mcp/security.ts (1)

13-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Naming nit: this is a fixed-window limiter, not a token bucket. Tokens only refill in a single burst once elapsed >= windowMs, so up to 2 * maxTokens requests can pass across a window boundary. Fine for a single-user stdio server; consider renaming the comment/class or refilling proportionally if smoother limiting is ever needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/mcp/security.ts` around lines 13 - 34, Rename the
“token-bucket” terminology in the comment and RateLimiter class to describe the
implemented fixed-window behavior, updating references to RateLimiter as needed.
Preserve the existing bursty refill logic; do not add proportional refilling.
apps/subtrack/src/stats.ts (2)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import getFileSize to eliminate duplicated logic.

You can import getFileSize here as well to replace the manual statSync try/catch block below.

♻️ Proposed fix
-import { formatBytes } from "./format.ts"
+import { formatBytes, getFileSize } from "./format.ts"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/stats.ts` at line 10, Update the stats logic using the
manual statSync try/catch to import and reuse getFileSize, replacing the
duplicated file-size retrieval while preserving the existing behavior and
output.

62-66: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use getFileSize to replace this block.

Since getFileSize encapsulates this exact try/catch and statSync fallback behavior, you can replace this block with a single function call.

♻️ Proposed fix
-  // DB size
-  let dbSizeBytes = 0
-  try {
-    dbSizeBytes = statSync(getDbPath()).size
-  } catch { /* ignore */ }
+  // DB size
+  const dbSizeBytes = getFileSize(getDbPath())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/stats.ts` around lines 62 - 66, Replace the local
dbSizeBytes initialization and statSync try/catch block with a call to
getFileSize, preserving the existing database path argument and resulting
byte-size value. Update the surrounding stats flow without changing its
behavior.
apps/subtrack/src/config.ts (1)

117-140: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared IMAP-defaults merge helper.

Each case rebuilds config.imap with the same four fallback defaults (port ?? 993, tls ?? true, etc.), duplicated four times. A small helper reduces drift risk if defaults ever change.

♻️ Proposed refactor
+function mergeImapConfig(config: SubtrackConfig, patch: Partial<ImapConfig>): ImapConfig {
+  return {
+    host: patch.host ?? config.imap?.host ?? "",
+    port: patch.port ?? config.imap?.port ?? 993,
+    tls: patch.tls ?? config.imap?.tls ?? true,
+    username: patch.username ?? config.imap?.username ?? "",
+  }
+}
+
 case "imapHost":
   if (!value) { consola.error("imapHost must not be empty"); return false }
-  config.imap = { ...config.imap, host: value, port: config.imap?.port ?? 993, tls: config.imap?.tls ?? true, username: config.imap?.username ?? "" }
+  config.imap = mergeImapConfig(config, { host: value })
   break
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/config.ts` around lines 117 - 140, Extract the repeated
IMAP fallback construction from the imapHost, imapPort, imapTls, and
imapUsername branches into a shared helper that merges partial updates with the
established defaults for host, port, tls, and username. Update each branch to
use this helper while preserving its existing validation and field-specific
values.
apps/subtrack/src/suggest/email-parser.ts (1)

182-195: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

MIME header decoder only supports base64 (?B?) words, always as UTF-8.

Quoted-printable (?Q?) encoded-word subjects/from headers pass through undecoded, and the declared charset is ignored (bytes are always interpreted as UTF-8). Given the parsers explicitly target Japanese subject keywords elsewhere in this feature, non-UTF8-charset or Q-encoded Japanese subjects will silently fail to decode, degrading match rates without crashing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/email-parser.ts` around lines 182 - 195, The
decodeMimeHeader function only handles base64 encoded-words and always decodes
bytes as UTF-8. Extend it to parse the declared charset and support both B and Q
encodings, including Q rules for underscores and hexadecimal escapes, then
convert the decoded bytes using the declared charset with an appropriate
fallback while preserving undecodable text safely.
apps/subtrack/src/suggest/parser/index.ts (1)

25-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Correct the comment about genericParser.

The comment states that genericParser never returns null, but its implementation in generic.ts actually does return null if it cannot extract a price from the email.

♻️ Proposed refactor
 /**
  * Try each parser in order and return the first matching result.
- * Returns null only if all parsers return null (which genericParser never does).
+ * Returns null if all parsers, including the generic fallback, return null.
  */
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/parser/index.ts` around lines 25 - 29, Update the
documentation above parseEmail to accurately state that genericParser may return
null when it cannot extract a price, while preserving the description of parser
ordering and the all-parsers-null behavior.
apps/subtrack/src/suggest/types.ts (1)

54-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove duplicated ImapConfig type.

This type is already defined and exported in apps/subtrack/src/types.ts. You should import it from there to prevent duplication and ensure consistency.

♻️ Proposed refactor
-/** IMAP connection settings. */
-export type ImapConfig = {
-  host: string
-  port: number
-  tls: boolean
-  username: string
-}
+export type { ImapConfig } from "../types.ts"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/types.ts` around lines 54 - 60, Remove the local
ImapConfig declaration in the suggest types module and import the existing
exported ImapConfig from the shared types module instead. Update any references
to use that imported type, preserving the current fields and behavior.
apps/subtrack/src/suggest/parser/credit-card.ts (1)

86-95: 🎯 Functional Correctness | 🔵 Trivial | ⚖️ Poor tradeoff

Improve currency detection by capturing the symbol.

The current heuristic uses the presence of a decimal point to distinguish between USD and JPY. This is unreliable because whole-dollar amounts (e.g., $10) will be incorrectly interpreted as 10 JPY, and other currencies like £10.00 will be interpreted as USD.

Consider updating the amountPattern regexes to capture the currency symbol in a separate group, and map the captured symbol to the correct currency and scaling factor (similar to how it is handled in receipt.ts).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/parser/credit-card.ts` around lines 86 - 95, Update
the amountPattern regexes and parsing logic in the credit-card parser to capture
the currency symbol separately, then map that symbol to the appropriate currency
and scaling factor instead of using isDecimal. Ensure whole-dollar USD amounts
and symbols such as £ are classified correctly, following the existing approach
in receipt.ts, and update the amount validation flow to use the mapped value.
apps/subtrack/src/suggest/interactor.ts (1)

144-161: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant dynamic imports duplicate already-statically-imported members.

input and confirm are already imported statically at the top of the file (line 11); re-importing them dynamically here (159, 161) just shadows the outer bindings with the same module reference. Likewise, dismissSuggestion (144) could be added to the existing static "../db.ts" import instead of a fresh dynamic import.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/interactor.ts` around lines 144 - 161, Remove the
redundant dynamic imports of input and inquirerConfirm inside editSuggestion,
reusing the existing static bindings. Add dismissSuggestion to the existing
static ../db.ts import and update the dismissal case to use that binding instead
of dynamically importing ../db/suggestions.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/subtrack/src/date-utils.ts`:
- Around line 122-129: Update the "bi-weekly" branch in the current-period date
range calculation to subtract 13 days from d, producing a 14-day inclusive
window ending today. Preserve the existing date formatting and to value; do not
alter getPreviousPeriodDateRange unless needed for this minimum length
correction.

In `@apps/subtrack/src/mcp/handlers.ts`:
- Around line 226-270: The handleCompare function must apply the requested
period instead of only echoing it. Thread period into calcSubTotal and
calcPreviousTotals, or reuse the existing date-range calculation logic, so
monthly, quarterly, and yearly requests produce their corresponding totals while
preserving the current response structure.

In `@apps/subtrack/src/subscription/core.ts`:
- Around line 125-143: Validate the converted price in handleClone before
constructing or saving newData: when flags.price is provided, convert it to a
number and reject non-finite or NaN values with the existing command
error-handling behavior. Preserve sub.price when the flag is omitted, and only
assign a validated numeric value to the cloned subscription.

In `@apps/subtrack/src/subscription/edit.ts`:
- Around line 48-53: Update the hasFlags predicate in the subscription edit flow
to include flags.notes so --notes alone enters the flag-based update path and
reaches the existing notes handling. Also add notes to the interactive checkbox
choices so notes can be selected and edited interactively, preserving the
behavior of all existing choices.
- Around line 67-69: Update the edit flag handling before updateSubscription to
validate currency, cycle, and status using the existing isValidCurrency,
isValidCycle, and isValidStatus helpers, matching resolveAddOptions behavior;
only assign validated values to newData and reject invalid inputs before
persistence.

In `@apps/subtrack/src/suggest/email-parser.ts`:
- Around line 38-46: Update parseEml, extractTextBody, extractFromMultipart, and
decodeBase64Body to recognize both LF and CRLF blank-line separators by
replacing LF-only boundary detection and splitting with equivalent optional-CR
handling. Preserve the existing header, body, multipart, and base64 parsing
behavior after the separator is identified.

In `@apps/subtrack/src/suggest/imap.ts`:
- Around line 38-95: Update the IMAP scan flow around client.connect(),
getMailboxLock(), and the outer cleanup so logout runs only after a successful
connection, preserving the original connection error when connect fails. Honor
signal?.aborted before and during connection or mailbox-lock acquisition, using
the client/library’s supported abort or timeout mechanism so an aborted scan
cannot remain stuck in either operation.
- Around line 48-65: Update the searchQuery definition in the imap suggestion
flow to use ImapFlow’s typed SearchObject format: set since to sinceDate and
place the subject alternatives in the required nested or structure. Remove the
explicit any annotation and preserve all existing English and Japanese subject
terms.

In `@apps/subtrack/src/suggest/interactor.ts`:
- Around line 155-225: Update billingDay construction in editSuggestion to
validate the parsed emailDate before calling getDate, matching the add path’s
isNaN(d.getTime()) handling. Pass a valid day to writeSubscription and use the
existing null fallback when suggestion.emailDate is absent or malformed.

In `@apps/subtrack/src/suggest/parser/bank.ts`:
- Around line 102-111: Update the date extraction logic in the parser around the
dateMatch handling to distinguish unified dates captured in dateMatch[1] from
Generic Bank’s separate year, month, and day groups. Parse and normalize the
unified YYYY/MM/DD value directly, preserve the separated-group path for Generic
Bank, and remove the dangling dateMatch expression from the fallback branch.

In `@apps/subtrack/src/suggest/parser/generic.ts`:
- Around line 78-91: The parsers inconsistently scale monetary amounts because
they rely on decimal formatting instead of the resolved currency. In
apps/subtrack/src/suggest/parser/generic.ts#L78-L91, scale the resolved amount
by 100 for fractional currencies and 1 for zero-decimal currencies; apply the
same currency-based scaling in
apps/subtrack/src/suggest/parser/receipt.ts#L133-L137 and
apps/subtrack/src/suggest/parser/credit-card.ts#L87-L91, replacing each
isDecimal-based decision while preserving existing rounding and validation.

In `@apps/subtrack/src/suggest/parser/wallet.ts`:
- Around line 65-67: Update the tag sanitization in the wallet name parsing flow
around nameMatch and the name length check to repeatedly strip HTML-like tags
until the value is unchanged, or use the project’s established HTML-stripping
utility. Ensure the final persisted name contains no removable tags before
applying the existing empty and 100-character validation.

In `@apps/subtrack/src/suggest/scan.ts`:
- Around line 93-98: Update the connectAndSearch call in the scan flow to pass
sinceDays: 7 explicitly, preserving the existing abort signal and result
handling so the scan window matches the documented seven-day behavior.
- Around line 85-99: Update scanEmails so its timeout bounds the entire
connectAndSearch operation, including connection and mailbox-lock setup; ensure
the timeout rejects or aborts promptly even when those stages do not consume
controller.signal. Preserve the existing successful scan and no-results
behavior, and use the existing timeout configuration rather than allowing
ImapFlow’s default connection timeout to exceed it.

---

Nitpick comments:
In `@apps/subtrack/src/config.ts`:
- Around line 117-140: Extract the repeated IMAP fallback construction from the
imapHost, imapPort, imapTls, and imapUsername branches into a shared helper that
merges partial updates with the established defaults for host, port, tls, and
username. Update each branch to use this helper while preserving its existing
validation and field-specific values.

In `@apps/subtrack/src/mcp/security.ts`:
- Around line 13-34: Rename the “token-bucket” terminology in the comment and
RateLimiter class to describe the implemented fixed-window behavior, updating
references to RateLimiter as needed. Preserve the existing bursty refill logic;
do not add proportional refilling.

In `@apps/subtrack/src/stats.ts`:
- Line 10: Update the stats logic using the manual statSync try/catch to import
and reuse getFileSize, replacing the duplicated file-size retrieval while
preserving the existing behavior and output.
- Around line 62-66: Replace the local dbSizeBytes initialization and statSync
try/catch block with a call to getFileSize, preserving the existing database
path argument and resulting byte-size value. Update the surrounding stats flow
without changing its behavior.

In `@apps/subtrack/src/suggest/email-parser.ts`:
- Around line 182-195: The decodeMimeHeader function only handles base64
encoded-words and always decodes bytes as UTF-8. Extend it to parse the declared
charset and support both B and Q encodings, including Q rules for underscores
and hexadecimal escapes, then convert the decoded bytes using the declared
charset with an appropriate fallback while preserving undecodable text safely.

In `@apps/subtrack/src/suggest/interactor.ts`:
- Around line 144-161: Remove the redundant dynamic imports of input and
inquirerConfirm inside editSuggestion, reusing the existing static bindings. Add
dismissSuggestion to the existing static ../db.ts import and update the
dismissal case to use that binding instead of dynamically importing
../db/suggestions.ts.

In `@apps/subtrack/src/suggest/parser/credit-card.ts`:
- Around line 86-95: Update the amountPattern regexes and parsing logic in the
credit-card parser to capture the currency symbol separately, then map that
symbol to the appropriate currency and scaling factor instead of using
isDecimal. Ensure whole-dollar USD amounts and symbols such as £ are classified
correctly, following the existing approach in receipt.ts, and update the amount
validation flow to use the mapped value.

In `@apps/subtrack/src/suggest/parser/index.ts`:
- Around line 25-29: Update the documentation above parseEmail to accurately
state that genericParser may return null when it cannot extract a price, while
preserving the description of parser ordering and the all-parsers-null behavior.

In `@apps/subtrack/src/suggest/types.ts`:
- Around line 54-60: Remove the local ImapConfig declaration in the suggest
types module and import the existing exported ImapConfig from the shared types
module instead. Update any references to use that imported type, preserving the
current fields and behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e603bcd7-459a-4941-8d47-0aab193012e2

📥 Commits

Reviewing files that changed from the base of the PR and between 6901a30 and fe21bee.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (43)
  • apps/subtrack/package.json
  • apps/subtrack/src/backup.ts
  • apps/subtrack/src/cleanup.ts
  • apps/subtrack/src/commands/index.ts
  • apps/subtrack/src/commands/suggest.ts
  • apps/subtrack/src/compare.ts
  • apps/subtrack/src/config.ts
  • apps/subtrack/src/date-utils.ts
  • apps/subtrack/src/db.ts
  • apps/subtrack/src/db/schema.ts
  • apps/subtrack/src/db/suggestions.ts
  • apps/subtrack/src/format.ts
  • apps/subtrack/src/maintenance.ts
  • apps/subtrack/src/mcp.ts
  • apps/subtrack/src/mcp/handlers.ts
  • apps/subtrack/src/mcp/index.ts
  • apps/subtrack/src/mcp/security.ts
  • apps/subtrack/src/mcp/server.ts
  • apps/subtrack/src/mcp/tools.ts
  • apps/subtrack/src/mcp/types.ts
  • apps/subtrack/src/notifications/banner.ts
  • apps/subtrack/src/payment.ts
  • apps/subtrack/src/stats.ts
  • apps/subtrack/src/subscription.ts
  • apps/subtrack/src/subscription/add.ts
  • apps/subtrack/src/subscription/core.ts
  • apps/subtrack/src/subscription/edit.ts
  • apps/subtrack/src/suggest/email-parser.ts
  • apps/subtrack/src/suggest/imap.ts
  • apps/subtrack/src/suggest/interactor.ts
  • apps/subtrack/src/suggest/matcher.ts
  • apps/subtrack/src/suggest/parser/bank.ts
  • apps/subtrack/src/suggest/parser/credit-card.ts
  • apps/subtrack/src/suggest/parser/generic.ts
  • apps/subtrack/src/suggest/parser/index.ts
  • apps/subtrack/src/suggest/parser/receipt.ts
  • apps/subtrack/src/suggest/parser/wallet.ts
  • apps/subtrack/src/suggest/scan.ts
  • apps/subtrack/src/suggest/suggest.ts
  • apps/subtrack/src/suggest/types.ts
  • apps/subtrack/src/types.ts
  • apps/subtrack/src/upcoming.ts
  • apps/subtrack/src/usage-total.ts

Comment thread apps/subtrack/src/date-utils.ts
Comment thread apps/subtrack/src/mcp/handlers.ts
Comment thread apps/subtrack/src/subscription/core.ts
Comment on lines +48 to +53
const hasFlags =
flags.name !== undefined || flags.price !== undefined ||
flags.currency !== undefined || flags.cycle !== undefined ||
flags.tags !== undefined || flags.status !== undefined ||
flags.billingDay !== undefined ||
flags.paymentMethod !== undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--notes alone is silently ignored; notes can't be edited interactively either.

hasFlags (Lines 48-53) doesn't include flags.notes. When --notes is the only flag supplied, hasFlags is false, so execution falls through to interactive mode — but the interactive checkbox (Lines 107-116) has no notes option, so the notes-handling code at Lines 77-80 is unreachable in that case. Net effect: editing only notes is a no-op.

Add flags.notes to the hasFlags predicate (and consider adding a notes choice to the interactive checkbox for parity).

🐛 Proposed fix for the hasFlags predicate
   const hasFlags =
     flags.name !== undefined || flags.price !== undefined ||
     flags.currency !== undefined || flags.cycle !== undefined ||
     flags.tags !== undefined || flags.status !== undefined ||
     flags.billingDay !== undefined ||
+    flags.notes !== undefined ||
     flags.paymentMethod !== undefined
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasFlags =
flags.name !== undefined || flags.price !== undefined ||
flags.currency !== undefined || flags.cycle !== undefined ||
flags.tags !== undefined || flags.status !== undefined ||
flags.billingDay !== undefined ||
flags.paymentMethod !== undefined
const hasFlags =
flags.name !== undefined || flags.price !== undefined ||
flags.currency !== undefined || flags.cycle !== undefined ||
flags.tags !== undefined || flags.status !== undefined ||
flags.billingDay !== undefined ||
flags.notes !== undefined ||
flags.paymentMethod !== undefined
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/subscription/edit.ts` around lines 48 - 53, Update the
hasFlags predicate in the subscription edit flow to include flags.notes so
--notes alone enters the flag-based update path and reaches the existing notes
handling. Also add notes to the interactive checkbox choices so notes can be
selected and edited interactively, preserving the behavior of all existing
choices.

Comment thread apps/subtrack/src/subscription/edit.ts Outdated
Comment thread apps/subtrack/src/suggest/parser/bank.ts
Comment thread apps/subtrack/src/suggest/parser/generic.ts
Comment thread apps/subtrack/src/suggest/parser/wallet.ts
Comment thread apps/subtrack/src/suggest/scan.ts
Comment on lines +93 to +98
try {
const emails = await connectAndSearch(imapConfig, password, { signal: controller.signal })

if (emails.length === 0) {
consola.info("No relevant emails found in the last 30 days.")
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Scan window defaults to 30 days, not the documented 7 days.

connectAndSearch is called without sinceDays, so it falls back to its default of 30 (per imap.ts), while the PR describes a 7-day scan window. Consider passing sinceDays: 7 explicitly here to match the intended behavior.

🐛 Proposed fix
-    const emails = await connectAndSearch(imapConfig, password, { signal: controller.signal })
+    const emails = await connectAndSearch(imapConfig, password, { sinceDays: 7, signal: controller.signal })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const emails = await connectAndSearch(imapConfig, password, { signal: controller.signal })
if (emails.length === 0) {
consola.info("No relevant emails found in the last 30 days.")
return 0
try {
const emails = await connectAndSearch(imapConfig, password, { sinceDays: 7, signal: controller.signal })
if (emails.length === 0) {
consola.info("No relevant emails found in the last 30 days.")
return 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/scan.ts` around lines 93 - 98, Update the
connectAndSearch call in the scan flow to pass sinceDays: 7 explicitly,
preserving the existing abort signal and result handling so the scan window
matches the documented seven-day behavior.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/subtrack/src/__tests__/untested-commands.test.ts`:
- Around line 236-247: Ensure the fake timers configured in this test are always
restored by wrapping the setup and notify flow after vi.useFakeTimers() in a
try/finally block, placing vi.useRealTimers() in the finally clause. Keep the
existing test behavior unchanged while covering failures from insertSub, the
dynamic import, or handleNotify.

In `@apps/subtrack/src/suggest/parser/wallet.ts`:
- Around line 69-79: The amount conversion currently depends on decimal
formatting rather than the resolved currency. In the wallet parsing flow,
resolve currency from symbol before calculating amount, then scale USD, EUR, and
GBP values by 100 while leaving JPY values unscaled, preserving validation after
conversion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7aee9d38-7fb7-4811-b7b9-f3e829602cb6

📥 Commits

Reviewing files that changed from the base of the PR and between fe21bee and 7e9ade5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (28)
  • apps/subtrack/package.json
  • apps/subtrack/src/__tests__/untested-commands.test.ts
  • apps/subtrack/src/commands/index.ts
  • apps/subtrack/src/commands/suggest.ts
  • apps/subtrack/src/config.ts
  • apps/subtrack/src/db.ts
  • apps/subtrack/src/db/schema.ts
  • apps/subtrack/src/db/suggestions.ts
  • apps/subtrack/src/notifications/banner.ts
  • apps/subtrack/src/payment.ts
  • apps/subtrack/src/subscription/add.ts
  • apps/subtrack/src/subscription/core.ts
  • apps/subtrack/src/suggest/email-parser.ts
  • apps/subtrack/src/suggest/imap.ts
  • apps/subtrack/src/suggest/interactor.ts
  • apps/subtrack/src/suggest/matcher.ts
  • apps/subtrack/src/suggest/parser/bank.ts
  • apps/subtrack/src/suggest/parser/credit-card.ts
  • apps/subtrack/src/suggest/parser/generic.ts
  • apps/subtrack/src/suggest/parser/index.ts
  • apps/subtrack/src/suggest/parser/receipt.ts
  • apps/subtrack/src/suggest/parser/wallet.ts
  • apps/subtrack/src/suggest/scan.ts
  • apps/subtrack/src/suggest/suggest.ts
  • apps/subtrack/src/suggest/types.ts
  • apps/subtrack/src/types.ts
  • apps/subtrack/src/upcoming.ts
  • apps/subtrack/vitest.config.ts
🚧 Files skipped from review as they are similar to previous changes (21)
  • apps/subtrack/package.json
  • apps/subtrack/src/commands/index.ts
  • apps/subtrack/src/notifications/banner.ts
  • apps/subtrack/src/db.ts
  • apps/subtrack/src/upcoming.ts
  • apps/subtrack/src/suggest/imap.ts
  • apps/subtrack/src/db/schema.ts
  • apps/subtrack/src/commands/suggest.ts
  • apps/subtrack/src/subscription/add.ts
  • apps/subtrack/src/subscription/core.ts
  • apps/subtrack/src/suggest/interactor.ts
  • apps/subtrack/src/suggest/parser/receipt.ts
  • apps/subtrack/src/payment.ts
  • apps/subtrack/src/suggest/types.ts
  • apps/subtrack/src/suggest/suggest.ts
  • apps/subtrack/src/suggest/parser/index.ts
  • apps/subtrack/src/suggest/matcher.ts
  • apps/subtrack/src/suggest/scan.ts
  • apps/subtrack/src/config.ts
  • apps/subtrack/src/suggest/email-parser.ts
  • apps/subtrack/src/db/suggestions.ts

Comment thread apps/subtrack/src/__tests__/untested-commands.test.ts
Comment on lines +69 to +79
const symbol = amountMatch[1]
const rawAmount = amountMatch[2].replace(/,/g, "")
const isDecimal = rawAmount.includes(".")
const amount = isDecimal
? Math.round(parseFloat(rawAmount) * 100)
: parseInt(rawAmount, 10)

if (isNaN(amount) || amount <= 0 || amount > 99999999) continue

const currencyMap: Record<string, string> = { "$": "USD", "¥": "JPY", "€": "EUR", "£": "GBP" }
const currency = symbol ? (currencyMap[symbol] ?? "USD") : "USD"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Scale by resolved currency, not decimal formatting.

$10 is stored as 10 USD units, while $10.00 is stored as 1000. Resolve currency first, then apply a 100 multiplier for USD/EUR/GBP and 1 for JPY.

Proposed fix
 const symbol = amountMatch[1]
 const rawAmount = amountMatch[2].replace(/,/g, "")
-const isDecimal = rawAmount.includes(".")
-const amount = isDecimal
-  ? Math.round(parseFloat(rawAmount) * 100)
-  : parseInt(rawAmount, 10)
+const currencyMap: Record<string, string> = { "$": "USD", "¥": "JPY", "€": "EUR", "£": "GBP" }
+const currency = symbol ? (currencyMap[symbol] ?? "USD") : "USD"
+const amount = Math.round(
+  parseFloat(rawAmount) * (currency === "JPY" ? 1 : 100),
+)
 
 if (isNaN(amount) || amount <= 0 || amount > 99999999) continue
 
-const currencyMap: Record<string, string> = { "$": "USD", "¥": "JPY", "€": "EUR", "£": "GBP" }
-const currency = symbol ? (currencyMap[symbol] ?? "USD") : "USD"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const symbol = amountMatch[1]
const rawAmount = amountMatch[2].replace(/,/g, "")
const isDecimal = rawAmount.includes(".")
const amount = isDecimal
? Math.round(parseFloat(rawAmount) * 100)
: parseInt(rawAmount, 10)
if (isNaN(amount) || amount <= 0 || amount > 99999999) continue
const currencyMap: Record<string, string> = { "$": "USD", "¥": "JPY", "€": "EUR", "£": "GBP" }
const currency = symbol ? (currencyMap[symbol] ?? "USD") : "USD"
const symbol = amountMatch[1]
const rawAmount = amountMatch[2].replace(/,/g, "")
const currencyMap: Record<string, string> = { "$": "USD", "¥": "JPY", "€": "EUR", "£": "GBP" }
const currency = symbol ? (currencyMap[symbol] ?? "USD") : "USD"
const amount = Math.round(
parseFloat(rawAmount) * (currency === "JPY" ? 1 : 100),
)
if (isNaN(amount) || amount <= 0 || amount > 99999999) continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/parser/wallet.ts` around lines 69 - 79, The amount
conversion currently depends on decimal formatting rather than the resolved
currency. In the wallet parsing flow, resolve currency from symbol before
calculating amount, then scale USD, EUR, and GBP values by 100 while leaving JPY
values unscaled, preserving validation after conversion.

@github-actions github-actions Bot added the ci label Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
pnpm-workspace.yaml (1)

22-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bound the new override ranges to their compatible major versions.

These open-ended >= ranges can select a future major release during a lockfile refresh. Use ranges such as >=2.1.2 <3.0.0, or pin the exact patched versions, to preserve the intended compatibility boundary. pnpm applies overrides to the dependency graph, so these ranges control future resolutions. (pnpm.io)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pnpm-workspace.yaml` around lines 22 - 24, Bound the dependency override
ranges for brace-expansion, js-yaml, and fast-uri to their compatible major
versions by adding upper bounds such as <3.0.0, <5.0.0, and <4.0.0 respectively,
or pin each to an exact patched version. Preserve the existing minimum versions
while preventing future major releases during lockfile resolution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/subtrack/src/suggest/email-parser.ts`:
- Around line 138-141: Update extractFromMultipart so each selected MIME part is
decoded according to its own Content-Transfer-Encoding header before assigning
textPart and passing it to suggestion parsers. Handle base64 and
quoted-printable appropriately, while preserving plain 7bit/8bit payloads and
the existing header/body extraction behavior.
- Around line 182-203: Update decodeMimeHeader so Base64 encoded-word bytes are
decoded using the declared charset rather than always UTF-8, preserving an
ISO-2022-JP-aware mapping and fallback for unsupported charsets. Apply the same
raw-byte, charset-aware conversion to the Q-encoded path, ensuring values such
as ISO-8859-1 encoded words decode correctly.

In `@apps/subtrack/src/suggest/parser/wallet.ts`:
- Around line 80-84: Update the amount parsing in the wallet suggestion parser
so JPY uses numeric parsing without truncation, then reject non-integer JPY
amounts before accepting the candidate. Preserve the existing positive and
maximum amount checks and the non-JPY conversion behavior.

---

Nitpick comments:
In `@pnpm-workspace.yaml`:
- Around line 22-24: Bound the dependency override ranges for brace-expansion,
js-yaml, and fast-uri to their compatible major versions by adding upper bounds
such as <3.0.0, <5.0.0, and <4.0.0 respectively, or pin each to an exact patched
version. Preserve the existing minimum versions while preventing future major
releases during lockfile resolution.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c3822b6b-28aa-4bce-b189-77dcee301aa6

📥 Commits

Reviewing files that changed from the base of the PR and between 7e9ade5 and e26c770.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (21)
  • .github/workflows/dependency-review.yml
  • apps/subtrack/src/__tests__/untested-commands.test.ts
  • apps/subtrack/src/config.ts
  • apps/subtrack/src/date-utils.ts
  • apps/subtrack/src/mcp/handlers.ts
  • apps/subtrack/src/mcp/security.ts
  • apps/subtrack/src/payment.ts
  • apps/subtrack/src/stats.ts
  • apps/subtrack/src/subscription/core.ts
  • apps/subtrack/src/subscription/edit.ts
  • apps/subtrack/src/suggest/email-parser.ts
  • apps/subtrack/src/suggest/imap.ts
  • apps/subtrack/src/suggest/interactor.ts
  • apps/subtrack/src/suggest/parser/bank.ts
  • apps/subtrack/src/suggest/parser/credit-card.ts
  • apps/subtrack/src/suggest/parser/generic.ts
  • apps/subtrack/src/suggest/parser/index.ts
  • apps/subtrack/src/suggest/parser/receipt.ts
  • apps/subtrack/src/suggest/parser/wallet.ts
  • apps/subtrack/src/suggest/types.ts
  • pnpm-workspace.yaml
🚧 Files skipped from review as they are similar to previous changes (16)
  • apps/subtrack/src/tests/untested-commands.test.ts
  • apps/subtrack/src/suggest/parser/credit-card.ts
  • apps/subtrack/src/stats.ts
  • apps/subtrack/src/suggest/parser/index.ts
  • apps/subtrack/src/suggest/parser/bank.ts
  • apps/subtrack/src/suggest/imap.ts
  • apps/subtrack/src/date-utils.ts
  • apps/subtrack/src/suggest/types.ts
  • apps/subtrack/src/mcp/security.ts
  • apps/subtrack/src/subscription/edit.ts
  • apps/subtrack/src/config.ts
  • apps/subtrack/src/payment.ts
  • apps/subtrack/src/suggest/interactor.ts
  • apps/subtrack/src/suggest/parser/receipt.ts
  • apps/subtrack/src/suggest/parser/generic.ts
  • apps/subtrack/src/mcp/handlers.ts

Comment on lines +138 to +141
// Extract content after the header block (CRLF or LF)
const contentStart = part.search(/\r?\n\r?\n/)
if (contentStart !== -1) {
textPart = part.slice(contentStart + (part[contentStart] === '\r' ? 4 : 2)).trim()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Decode each selected MIME part by its own transfer encoding.

A text/plain multipart part with Content-Transfer-Encoding: base64 is selected here, but extractFromMultipart later applies decodeQp unconditionally. Its encoded payload reaches all suggestion parsers instead of readable text.

Also applies to: 152-154

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/email-parser.ts` around lines 138 - 141, Update
extractFromMultipart so each selected MIME part is decoded according to its own
Content-Transfer-Encoding header before assigning textPart and passing it to
suggestion parsers. Handle base64 and quoted-printable appropriately, while
preserving plain 7bit/8bit payloads and the existing header/body extraction
behavior.

Comment on lines +182 to +203
/** Minimal MIME encoded-word decoder for Subject/From headers.
* Supports both Base64 (?B?) and Quoted-printable (?Q?) encoding,
* respecting the declared charset. */
function decodeMimeHeader(header: string | null): string | null {
if (!header) return null
return header.replace(
/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
(_, charset: string, encoding: string, encoded: string) => {
try {
if (encoding.toUpperCase() === "B") {
return Buffer.from(encoded, "base64").toString("utf-8")
}
// Q-encoding: replace _ with space, decode =FF hex escapes
const qDecoded = encoded
.replace(/_/g, " ")
.replace(/=([0-9A-Fa-f]{2})/g, (__, hex) => String.fromCharCode(parseInt(hex, 16)))
// Try to decode using the declared charset; fall back to utf-8
try {
return Buffer.from(qDecoded, "latin1").toString(charset.toLowerCase() === "iso-2022-jp" ? "utf-8" : charset as BufferEncoding)
} catch {
return qDecoded
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the repository's declared Node.js target and locate MIME-decoding tests.
fd -HI 'package.json' 'tsconfig*.json' '.nvmrc' '.node-version' . -x sh -c 'echo "--- $1"; sed -n "1,160p" "$1"' _ {}
rg -n -i --glob '*.{test,spec}.{ts,tsx}' 'parseEmailContent|decodeMimeHeader|iso-2022-jp|iso-8859|encoded-word' apps/subtrack

Repository: nazozokc/subtrack

Length of output: 3522


🏁 Script executed:

#!/bin/bash
set -u

echo "## email-parser locations"
fd -HI 'email-parser.ts' . -x sh -c 'echo "--- $1"; wc -l "$1"; ast-grep outline "$1" | sed -n "1,220p"' _ {}

echo "## relevant email-parser section"
file="$(fd -HI 'email-parser.ts' . | head -n 1)"
if [ -n "${file:-}" ]; then
  sed -n '1,260p' "$file" | cat -n
fi

echo "## MIME header usages/tests"
rg -n -i 'decodeMimeHeader|parseEmailContent|encoded-word|encodedWord|iso-8859|iso-2022-jp|latin1' . --glob '*.{ts,tsx,mts,cts,json,yml,yaml,md}' | sed -n '1,220p'

echo "## behavioral probe of current implementation for ISO-8859-1 Base64"
node - <<'JS'
const inputs = [
  '=?ISO-8859-1?B?6Q==?=',      // ISO-8859-1 0xE9 = é
  '=?iso-2022-jp?B?GyRBOA?=?=',  // ISO-2022-JP Shift-JIS representation for あ
]
const decoder = new TextDecoder('utf-8', { fatal: false })
for (const header of inputs) {
  const decoded = header.replace(
    /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
    (_, charset, encoding, encoded) => {
      if (encoding.toUpperCase() === 'B') {
        return Buffer.from(encoded, 'base64').toString('utf-8')
      }
      const qDecoded = encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      try {
        return Buffer.from(qDecoded, 'latin1').toString(charset.toLowerCase() === 'iso-2022-jp' ? 'utf-8' : charset)
      } catch {
        return qDecoded
      }
    }
  )
  console.log(JSON.stringify({ header, decoded, ok: header === decoded }))
}
JS

Repository: nazozokc/subtrack

Length of output: 9604


🏁 Script executed:

#!/bin/bash
set -u

echo "## MIME decoder behavior variants"
node - <<'JS'
const cases = [
  ['ISO-8859-1 Base64', '=?ISO-8859-1?B?6Q==?=', 'é'],
  ['ISO-2022-JP Base64', '=?ISO-2022-JP?B?GyRBOA?=?=', 'あ'],
  ['ISO-8859-1 Q', '=?ISO-8859-1?Q?E9_a=42?=', 'é a*'],
]
function current(header) {
  return header.replace(
    /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
    (_, charset, encoding, encoded) => {
      if (encoding.toUpperCase() === 'B') return Buffer.from(encoded, 'base64').toString('utf-8')
      const q = encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      try {
        return Buffer.from(q, 'latin1').toString(charset.toLowerCase() === 'iso-2022-jp' ? 'utf-8' : charset)
      } catch {
        return q
      }
    }
  )
}
function suggestedTextDecoder(header) {
  const dec = new TextDecoder(encodingLabelFromCharset, { fatal: false })
  function encodingLabelFromCharset(charset) {
    switch (charset.toLowerCase()) {
      case 'iso-2022-jp': return 'iso-2022-jp'
      case 'iso-8859-7': return 'windows-1253'
      default: return charset
    }
  }
  return header.replace(
    /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
    (_, charset, encoding, encoded) => {
      const label = encodingLabelFromCharset(charset)
      if (encoding.toUpperCase() === 'B') {
        return dec.decode(Buffer.from(encoded, 'base64'))
      }
      const q = encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      return Buffer.from(q, 'latin1')
    }
  )
}
function suggestedBufferDecoder(header) {
  return header.replace(
    /=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g,
    (_, charset, encoding, encoded) => {
      if (encoding.toUpperCase() === 'B') {
        return Buffer.from(encoded, 'base64').toString(charset.toLowerCase() === 'iso-8859-7' ? 'windows-1253' : charset)
      }
      const q = encoded.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
      return Buffer.from(q, 'latin1').toString(charset.toLowerCase() === 'iso-8859-7' ? 'windows-1253' : charset)
    }
  )
}
for (const [name, header, expected] of cases) {
  console.log(JSON.stringify({ name, header, expected, currentCurrent: current(header), suggestedBufferDecoder, decoded: suggestedBufferDecoder(header) }))
}
JS

echo "## current Node charset support"
node - <<'JS'
const dec = new TextDecoder('iso-2022-jp', { fatal: false })
const b64 = Buffer.from('GyRBOA==', 'base64')
for (const enc of ['latin1', 'utf-8', 'iso-2022-jp']) {
  try {
    console.log(JSON.stringify({ encoding: enc, result: dec.decode(Buffer.from(b64, enc)) }))
  } catch (e) {
    console.log(JSON.stringify({ encoding: enc, error: String(e.message || e) }))
  }
}
console.log(JSON.stringify({ BufferSupported: Buffer.from('e9', 'hex').toString('iso-8859-1') }))
JS

echo "## package runtime context"
sed -n '1,70p' package.json apps/subtrack/package.json pnpm-workspace.yaml 2>/dev/null | cat -n

Repository: nazozokc/subtrack

Length of output: 3911


Honor the declared charset in MIME encoded-word decoding.

decodeMimeHeader() always decodes Base64 as UTF-8, corrupting values like =?ISO-8859-1?B?6Q==?= and invalidates the current catch fallback. Decode the raw bytes with the declared charset (with an ISO-2022-JP-aware mapping if needed) and apply the same charset-aware handling to the Q path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/email-parser.ts` around lines 182 - 203, Update
decodeMimeHeader so Base64 encoded-word bytes are decoded using the declared
charset rather than always UTF-8, preserving an ISO-2022-JP-aware mapping and
fallback for unsupported charsets. Apply the same raw-byte, charset-aware
conversion to the Q-encoded path, ensuring values such as ISO-8859-1 encoded
words decode correctly.

Comment on lines +80 to +84
const amount = currency === "JPY"
? parseInt(rawAmount, 10)
: Math.round(parseFloat(rawAmount) * 100)

if (isNaN(amount) || amount <= 0 || amount > 99999999) continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject fractional JPY values instead of truncating them.

parseInt("1.50", 10) stores ¥1.50 as 1. Parse numerically, then require an integer for JPY before returning the candidate.

Proposed fix
-    const amount = currency === "JPY"
-      ? parseInt(rawAmount, 10)
-      : Math.round(parseFloat(rawAmount) * 100)
+    const numericAmount = Number(rawAmount)
+    const amount = currency === "JPY"
+      ? numericAmount
+      : Math.round(numericAmount * 100)
 
-    if (isNaN(amount) || amount <= 0 || amount > 99999999) continue
+    if (
+      !Number.isFinite(amount) ||
+      amount <= 0 ||
+      amount > 99999999 ||
+      (currency === "JPY" && !Number.isInteger(amount))
+    ) continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const amount = currency === "JPY"
? parseInt(rawAmount, 10)
: Math.round(parseFloat(rawAmount) * 100)
if (isNaN(amount) || amount <= 0 || amount > 99999999) continue
const numericAmount = Number(rawAmount)
const amount = currency === "JPY"
? numericAmount
: Math.round(numericAmount * 100)
if (
!Number.isFinite(amount) ||
amount <= 0 ||
amount > 99999999 ||
(currency === "JPY" && !Number.isInteger(amount))
) continue
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/subtrack/src/suggest/parser/wallet.ts` around lines 80 - 84, Update the
amount parsing in the wallet suggestion parser so JPY uses numeric parsing
without truncation, then reject non-integer JPY amounts before accepting the
candidate. Preserve the existing positive and maximum amount checks and the
non-JPY conversion behavior.

@nazozokc
nazozokc merged commit 5472480 into main Jul 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants