DEV Community

Cover image for Swordphish: Visualizing Email Phishing Attacks with Postmark
Jhade McConnell
Jhade McConnell

Posted on

Swordphish: Visualizing Email Phishing Attacks with Postmark

This is a submission for the Postmark Challenge: Inbox Innovators.

What I Built

Swordphish is an application that analyzes metadata associated with an email to detect and visualize phishing attempts. Swordphish has simple detection logic to highlight commonly used words and phrases used in phishing attacks.

The key Postmark features it uses are:

  1. Postmark’s inbound processing to process an email.
  2. Postmark Messages API to retrieve the JSON of the email.

Image description

Swordphish visualizes suspicious content within your email and displays its threat indicators.

Demo

Image description

You can test the frontend here or watch a quick walk through on Loom.

Code Repository

Swordphish has two repositories:

How I Built It

Inspiration

When I build applications on top of another service, the first thing I do is review its API responses and documentation. This is what I look for:

  • Interesting information returned by the API
  • The complexity of its JSON objects
  • Are the API response attributes documented anywhere
  "OriginalRecipient": "451d9b70cf9364d23ff6f9d51d870251569e+ahoy@inbound.postmarkapp.com",
  "ReplyTo": "myUsersReplyAddress@theirDomain.com",
  "Subject": "This is an inbound message",
  "MessageID": "22c74902-a0c1-4511-804f-341342852c90",
  "Date": "Thu, 5 Apr 2012 16:59:01 +0200",
  "MailboxHash": "ahoy",
  "TextBody": "[ASCII]",
  "HtmlBody": "[HTML]",
  "StrippedTextReply": "Ok, thanks for letting me know!",
  "Tag": "",
  "Headers": [
    {
      "Name": "X-Spam-Checker-Version",
      "Value": "SpamAssassin 3.3.1 (2010-03-16) onrs-ord-pm-inbound1.wildbit.com"
    },
    {
      "Name": "X-Spam-Status",
      "Value": "No"
    }
    ],
  "Attachments": [
    {
      "Name": "mypaper.doc",
      "Content": "[BASE64-ENCODED CONTENT]",
      "ContentType": "application/msword",
      "ContentLength": 16384,
      "ContentID": ""
    }
  ]
Enter fullscreen mode Exit fullscreen mode

I found the “HtmlBody”, “Headers”, and "Attachments" attributes interesting in Postmark's API response.

This information is available within emails as metadata, but it’s rare for traditional email services to display this technical information to the end user.

Brainstorming Features

A big inspiration of this project is to be able to uncover phishing attempts within emails in real-time. I outlined these features for Swordphish's proof of concept:

  • Use Postmark’s inbound processing feature to parse an email
  • Provide simple insights that analyzes the email’s headers and content
  • Use Postmark’s Message API to retrieve the raw JSON of inbound emails

I outline the key features before building because it’s easy to get side tracked when you start the development process. Doing this helps me focus on what to build.

Starting from Scratch

SvelteKit is an app framework that hides a lot of the complexity (like server side rendering, integrating TypeScript, and library packaging) when building web applications.

I decided to use SvelteKit to build Swordphish’s frontend with. Its backend is a Python server that:

  • exposes API endpoints using FastAPI
  • integrates with Postmark’s APIs to handle inbound email processing and email retrieval
  • uses a local SQLite database to store information
  • calls Google Gemini 2.5 flash for AI analysis

With a simple client server architecture in place, I started building out Swordphish’s features.

Feature 1: Process Inbound Emails

When architecting web API’s, I try to keep the JSON responses simple. Postmark’s dev team does a great job at this, which makes it straightforward to integrate email features into your application.

My webhook can simply wait for Postmark to process an inbound email, and then I can parse the JSON and use it to create my custom object.

@app.post("/webhook")
async def receive_webhook(request: Request, db: Session = Depends(get_db)):
    payload = await request.json()

    print(f"Received webhook payload: {payload}")

    try:
        # Create metadata dictionary from headers
        metadata = {h['Name']: h['Value'] for h in payload.get('Headers', [])}

        # Check if 'Received-SPF' is in metadata
        spf_value = metadata.get('Received-SPF')

        if spf_value:
            # Extract client-ip using regex
            match = re.search(r'client-ip=([\d\.]+)', spf_value)
            if match:
                client_ip = match.group(1)
        else:
            logging.warning("Received-SPF value is missing or None.")
            client_ip = None

        # Prepare email content for analysis
        email_content = f"""
        Subject: {payload.get('Subject')}
        From: {payload.get('From')}
        From Name: {payload.get('FromName')}
        SPF: {spf_value}
        HTML Body: {payload.get('HtmlBody')}
        """

        # CODE CONDENSED

           metadata.update({
            "Return-Path": payload.get("From"),
            "Date": payload.get("Date"),
            "From": payload.get("From"),
            "fromName": payload.get("FromName"),
            "htmlBody": payload.get("HtmlBody"),
            "aiAnalysis": analysis_response,
            "aiSenderAnalysis": sender_response,
            "aiSecurityAnalysis": security_response,
            "ipContext": {
                "ip": client_ip,
                "location": '',
                "asn": ''
            },
        })
Enter fullscreen mode Exit fullscreen mode

Postmark’s API response looks complex at first glance, but their JSON objects are simple and predictable. It’s actually very easy for builders to parse.

Now that I can process inbound emails with Postmark, I can begin to build my core features around its API response.

Feature 2: Analyze the Email’s Headers and Content

A rule of thumb when creating your own JSON objects is to keep it simple. Stay away from nested JSON objects and try to keep the JSON as flat as possible.

This is the JSON object that Swordphish's frontend will use to display information back to the user:

    {
        "id": 0,
        "messageId": "2e80bc5b-f613-432d-87e1-3d9b0c588b60",
        "subject": "Important: Verify Your Bank Information",
        "sender": "alerts@secure-bankupdate.com",
        "date": "2025-05-25 00:00:00",
        "metadatas": {
            "X-Spam-Checker-Version": "SpamAssassin 3.4.1",
            "X-Spam-Status": "Yes",
            "X-Spam-Score": "8.2",
            "X-Spam-Tests": "URGENT_REQUEST,SPOOFED_DOMAIN,BANK_PHISH",
            "Received-SPF": "fail",
            "Return-Path": "alerts@secure-bankupdate.com",
            "Date": "Sun, 25 May 2025 10:22:45 +0000",
            "From": "alerts@secure-bankupdate.com",
            "fromName": "SecureBank Alerts",
            "htmlBody": "<p>Your account has been flagged. <a href='http://secure-bankupdate.com/verify'>Click here to verify your banking info</a> to avoid suspension.</p>",
            "aiAnalysis": "Urgent financial request with spoofed sender domain. Highly likely phishing.",
            "ipContext": {
                "ip": "103.21.244.15",
                "location": "Mumbai, India",
                "asn": "AS55836 Reliance Jio Infocomm Limited"
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Inside of my /webhook logic, I save the email’s metadata and content inside of my JSON object, and then perform operations to enrich that information.

In this code example I use Gemini to analyze the email content, and then save Gemini's response under the "aiAnalysis" attribute:

        # Create tasks for concurrent execution
        analysis_task = generate_ai_content(analysis_prompt)
        sender_task = generate_ai_content(sender_prompt)
        security_task = generate_ai_content(security_prompt)

        # Gather all responses concurrently
        analysis_response, sender_response, security_response = await asyncio.gather(
            analysis_task,
            sender_task,
            security_task
        )

        metadata.update({
            "Return-Path": payload.get("From"),
            "Date": payload.get("Date"),
            "From": payload.get("From"),
            "fromName": payload.get("FromName"),
            "htmlBody": payload.get("HtmlBody"),
            "aiAnalysis": analysis_response,
            "aiSenderAnalysis": sender_response,
            "aiSecurityAnalysis": security_response,
            "ipContext": {
                "ip": client_ip,
                "location": '',
                "asn": ''
            },
        })
Enter fullscreen mode Exit fullscreen mode

Enriching IP Address Through a Reverse Lookup

Image description
The "Sender Reputation" tab shows the ASN information for the IP address extracted from "Received-SPF"

Postmark’s API response returns the “Received-SPF” object. The “Received-SPF” contains the “client-ip” value, which is the IP address of the SMTP client the email was sent from:

    {
      "Name": "Received-SPF",
      "Value": "Pass (sender SPF authorized) identity=mailfrom; client-ip=209.85.160.180; helo=mail-gy0-f180.google.com; envelope-from=myUser@theirDomain.com; receiver=451d9b70cf9364d23ff6f9d51d870251569e+ahoy@inbound.postmarkapp.com"
    }
Enter fullscreen mode Exit fullscreen mode

You can do a reverse lookup of this IP address to find its ASN. If the IP address and the ASN has a bad reputation, you can be on guard as the sender may not be reputable.

SpamAssassin Deep Dive

Image description
Apache SpamAssassin returns a spam score and performs tests to check if the email is likely spam.

Apache SpamAssassin is a widely used open source project to detect email spam. Postmark includes the tests ran through SpamAssassin in the email’s headers.

Here are some examples:

  • DKIM_VALID - Message has at least one valid DKIM or DK signature.
  • SPF_PASS - Sender matches SPF record.

SpamAssassin provides a risk score that tells you how likely the email can be considered spam. The “Spam Risk” tab of Swordphish visualizes this risk score, along with the SpamAssassin tests ran by Postmark.

Using an LLM to Analyze Email Content

Image description
Google Gemini provides AI security analysis at the top of each page.

The model Swordphish uses is Google’s Gemini 2.0 Flash-Lite. We configure Gemini to provide a high level security summary by prompting it to look for common phishing language.

You’ll see insights from “Swordphish AI” on the top of each page. We pass data from our custom JSON object into the prompt, and display what Gemini returns back from its analysis.

For these insights to be effective in a real-world scenario, extensive prompting with MCP servers to feed in additional context would be needed to accurately detect complex phishing attempts.

I recommend using a platform like Step One where you can monetize an LLM that you’ve worked hard to configure. That way, you can reward yourself by selling access to your chatbot.

Feature 3: Retrieving Inbound Email Logs

Image description
The raw JSON for an email parsed by Postmark.

We want to give security researchers the ability to inspect the email's raw JSON response. We can use Postmark’s Inbound Message Details API to retrieve the JSON response that was sent into our webhook:

curl "https://api.postmarkapp.com/messages/inbound/{messageid}/details" \
  -X GET \
  -H "Accept: application/json" \
  -H "X-Postmark-Server-Token: server token"
Enter fullscreen mode Exit fullscreen mode

If we pass in the “MessageID” associated with each email in the {messageid}, we’ll be returned the raw JSON response associated with that "MessageId".

Traditional email inboxes don't show this information because they know their average user isn’t technical. Displaying the raw email log for security researchers would allow them to use Swordphish for email forensics.

Challenges

Postmark's APIs are easy to use. Their documentation is straightforward, and the setup process for your account is a breeze with their support team.

I did notice that after I've activated my account, my Postmark server wasn’t fully processing inbound emails:

Image description

It was surprising to see this because the test webhook from the inbound processing settings showed that my application was ready to receive Postmark's webhooks:

Image description

On the "Email Analysis" tab my application successfully parsed the test email sent from Postmark:

Image description

I suspect this may be due to a network issue between Postmark and my application. I like how Postmark has sections outlined in their documentation that covers how to test and troubleshoot their APIs. After testing the inbound webhook with curl, my backend returned a successful response in my production environment.

I will have to reach out to Postmark support to check this issue, but I’m confident their support team will get me up and running.

Summary

Swordphish can be used as a tool to help visualize what phishing attacks look like through email.

Another challenge I faced was being able to consistently detect links within the “htmlBody” attribute. Since the “htmlBody” contents can contain unpredictable HTML, it’s best if an LLM was used to detect the links rather than a regex.

I would also update the fear, promotional, and urgent words list to be returned from an API response. This would allow the words list detection to be more dynamic as I can update words list in the backend alongside using an LLM.

I continue to use Postmark because of how straightforward their APIs are, and how friendly their support staff is. If you have any questions about the app, feel free to comment them!

Top comments (9)

Collapse
 
jhademcconnell profile image
Jhade McConnell

Image description

Saw this post had the “June 9” time stamp but I submitted my project based on this live timer. This was a great challenge to test out Postmark!

Collapse
 
rezelco profile image
Alex Tomita

Cool project! I actually started to build something in the same space, but I only found out about the contest on Friday and ran out of time. I got stuck on the SPF validation. I was trying to use a free 3rd party service, but that didn't work out. It was smart to use the one that gets embedded.

Collapse
 
jhademcconnell profile image
Jhade McConnell

Yeah the Postmark API response had a lot of interesting metadata if you look at the “Headers” object. I think another improvement could be to explain these headers in a user friendly way in the UI. Not everyone knows what “SPF validation” is. There’s another challenge going on now for AI agent prompting, should check it out.

Collapse
 
nevodavid profile image
Nevo David

Growth like this is always nice to see. Kinda makes me wonder - what keeps stuff going long-term? Like, beyond just the early hype?

Collapse
 
jhademcconnell profile image
Jhade McConnell

@nevodavid it just takes 1 dev to keep a project going I think. If someone is willing to invest their time towards a project, it’ll keep going until they stop.

That’s why I decided to open source this so that if someone is interested enough to continue building on top of the idea, it can be forked and continued.

This idea came to mind while I was reviewing Postmark’s API response. I can see it being a great tool for educators to teach kids about cybersecurity basics.

Collapse
 
dotallio profile image
Dotallio

This is really cool, love how you combine metadata, SpamAssassin, and AI for threat detection. Have you seen it catch any real phishing attempts in the wild yet?

Collapse
 
jhademcconnell profile image
Jhade McConnell

@dotallio I’ve forwarded it a couple of real emails and the AI analysis from Gemini did a decent job at assessing the email with the metadata and SpamAssassin info I gave it.

I really like the visualization for the words list and it caught a handful of those on these real emails. I think if the words list was dynamic instead of static though, we’d see more interesting results.

Collapse
 
katafrakt profile image
Paweł Świątkowski

great name!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.