My inbox is always full of notifications with subjects like [Ticket ID: 12345] Ticket Update. They’re useful for a few hours and then become noise. These aren’t emails that need to be archived, replied to, or revisited, so they just end up taking up mental space.

Deleting them manually is the kind of small task that never becomes a priority, but silently costs you in distraction. So I decided to treat it like any other recurring problem: automate it locally, without relying on external services, no webhooks, and no integrations. The idea is to periodically run a script that moves to the trash any messages whose subject matches a specific pattern and that are older than 48 hours.

Why not use a Mail rule#

The first instinct is to use Mail’s built-in rules, under Settings > Rules. They work well for acting the moment a message arrives, but that’s exactly the problem: they only run on the receive event.

Mail doesn’t offer a criterion like “apply this rule only if the message is older than 48 hours,” because there’s no age-based re-evaluation. With AppleScript, on the other hand, you can access Mail.app’s internal objects, including date received. This allows you to compare dates and make time-based decisions.

The script#

The core of the automation is this:

property subjectPattern : "[Ticket ID: "
property maxAgeHours : 48

on run
    set cutoffDate to (current date) - (maxAgeHours * 60 * 60)
    set totalDeleted to 0

    tell application "Mail"
        repeat with acct in accounts
            try
                set inboxMailbox to mailbox "INBOX" of acct

                set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received < cutoffDate)

                set msgCount to count of matchingMessages
                if msgCount > 0 then
                    repeat with i from msgCount to 1 by -1
                        try
                            delete item i of matchingMessages
                            set totalDeleted to totalDeleted + 1
                        on error errMsg
                            log "Error deleting message: " & errMsg
                        end try
                    end repeat
                end if
            on error errMsg
                log "Error accessing account: " & errMsg
            end try
        end repeat

        if totalDeleted > 0 then
            try
                check for new mail
            on error errMsg
                log "Warning: could not sync: " & errMsg
            end try
        end if

        log "Automation complete: " & totalDeleted & " message(s) moved to trash."
    end tell
end run

It iterates through all accounts configured in Mail, accesses each one’s INBOX, and selects only the messages whose subject contains the defined pattern and whose received date is earlier than the calculated cutoff.

Nothing is permanently deleted. The delete command in Apple Mail only moves messages to the respective account’s Trash, which gives you room to recover something if needed.

The detail that prevents a flood of errors#

The first version I wrote iterated message by message, something like this:

-- ❌ Problematic approach
repeat with msg in inboxMessages
    set msgSubject to subject of msg
    ...
end repeat

In practice, this turns into a series of errors like:

Mail got an error: Can't get message id 154604 of mailbox "INBOX" of account id "..."

The reason usually shows up with IMAP accounts: AppleScript maintains internal references by index or ID, and these references can become invalid when there’s an ongoing sync, index changes, or messages moved by the server during iteration.

The solution was to delegate the filtering to Mail itself using whose:

-- ✅ Correct approach
set matchingMessages to (messages of inboxMailbox whose subject contains subjectPattern and date received < cutoffDate)

In this format, the query is resolved internally by Mail, which returns only references it can materialize. In practice, this eliminates almost all intermittent errors.

Deleting in reverse order#

When removing items from a collection, order matters. If you iterate from first to last, the indices shift with each deletion and you may end up skipping messages.

That’s why the loop is reversed:

repeat with i from msgCount to 1 by -1
    delete item i of matchingMessages
end repeat

This avoids inconsistencies and keeps the behavior predictable.

contains instead of regex#

AppleScript has no native support for regular expressions. The contains operator works well enough when the pattern is a fixed substring like [Ticket ID:, and it avoids calling external processes.

You could use do shell script with grep, but that usually means spawning an external process per message, which is slower and unnecessary for this case.

Visual interface update#

After messages are moved to Trash, the Mail interface doesn’t always update immediately. The check for new mail command forces a sync that usually refreshes the list in practice.

Another alternative would be to toggle the selected mailbox via AppleScript, but that tends to break the context of composite views — which I use — like “All Inboxes,” and doesn’t always return to the previous state correctly.

Scheduling with launchd#

To run periodically, scheduling is handled by launchd, not cron — which macOS does have, but whose use is discouraged. The .plist goes in ~/Library/LaunchAgents/:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.user.delete-ticket-emails</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/osascript</string>
        <string>/Users/YOUR_USERNAME/Scripts/delete_old_ticket_emails.scpt</string>
    </array>

    <key>StartInterval</key>
    <integer>3600</integer>

    <key>RunAtLoad</key>
    <true/>

    <key>StandardOutPath</key>
    <string>/tmp/delete-ticket-emails.log</string>

    <key>StandardErrorPath</key>
    <string>/tmp/delete-ticket-emails-error.log</string>
</dict>
</plist>

StartInterval set to 3600 means it runs every hour. If the Mac is on, the script runs; if it isn’t, it runs the next time the session loads.

Note that the script path includes YOUR_USERNAME, which you should replace with your actual macOS username. If you need to find yours, run whoami in the Terminal.

To activate:

cp com.user.delete-ticket-emails.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.user.delete-ticket-emails.plist

On the first run, macOS will ask for permission for osascript to control Mail. This authorization is under System Settings > Privacy & Security > Automation. Without it, the script fails and the error isn’t always very informative.

Customizing#

To adapt this to other scenarios, just change two lines at the beginning of the script:

property subjectPattern : "[Ticket ID: "
property maxAgeHours : 48

You can use any substring in the subject and any time window in hours. The rest of the flow stays the same.

In the end, this isn’t just about an automation to delete emails. It’s about reducing cognitive friction with a simple, local, and controlled solution. Mail is still Mail, but you get to decide that some messages have an expiration date and that, after that, they leave the stage on their own.