← All ivy-nodes
IVYXSTUDIO · IVY NODE
M

Mail Mark

ivy.node.mail-mark · v0.2.0

ivyx✓

Marks messages of a mailbox folder as read or unread, moves them to another folder or adds a Gmail label, by uid over IMAP, and returns how many it changed. Use it after mail-fetch to leave a trace of what an agent handled: mark the triaged mail read, label it, or move it out of the inbox. On Gmail move_to "Archive" takes the mail out of the inbox the way the Archive button does, and any other folder is a label that must already exist. Needs at least one of seen, move_to or label.

#mail#email#imap#gmail#label#archive#mark

Inputs

FieldTypeDescription
imap_hoststringThe IMAP server; imap.gmail.com for Gmail.
imap_portintegerThe port: 993 over SSL.
use_sslbooleanConnect over SSL; off only for a local stand-in.
usernamerequiredstringThe mailbox address, which is the login name on Gmail.
mail_passwordstringThe mailbox password, an app password on Gmail, iCloud or Yahoo; reference the stored secret mail-password, never the password itself.
gmail_app_passwordstringThe 0.1.x name of mail_password, still read; leave it out.
folderstringThe folder to work in.
uidsrequiredNot declaredThe uids to change, as mail-fetch returns them.
seenbooleanTrue marks them read, false unread; leave it out to keep the flag.
move_tostringThe folder to move them to; on Gmail a label such as Archive.
labelstringA Gmail label to add, kept beside the folder.

Outputs

FieldTypeDescription
markedrequiredintegerHow many messages were changed.
uidsrequiredarrayThe uids changed.
folderrequiredstringThe folder they were in.

Source

python

inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]

import imaplib
import re
import socket

def connect():
    host = str(inp.get("imap_host") or "imap.gmail.com")
    port = int(inp.get("imap_port") or 993)
    username = str(inp.get("username") or "").strip()
    # mail_password since 0.2.0; gmail_app_password is the 0.1.x name, read so
    # an agent built on 0.1.x still runs where 0.2.0 is installed.
    password = inp.get("mail_password") or inp.get("gmail_app_password")
    if not username:
        raise ValueError("username is missing; give the mailbox address.")
    if not password:
        raise ValueError("mail_password is missing; reference the stored secret mail-password.")
    if re.search(r"(^|\.)(outlook\.office365\.com|outlook\.com|office365\.com)$", host, re.I):
        raise PermissionError(f"{host} does not take a password over IMAP: Microsoft 365 and Outlook.com sign in with OAuth, which this node does not do.")
    use_ssl = bool(inp.get("use_ssl", True))
    try:
        box = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port, timeout=30)
    except (OSError, socket.error) as exc:
        raise ConnectionError(f"Could not connect to {host}:{port}: {exc}")
    try:
        box.login(username, str(password))
    except imaplib.IMAP4.error as exc:
        box.logout()
        raise PermissionError(f"The mail server refused the login for {username}: {exc}")
    return box

def select(box, readonly):
    folder = str(inp.get("folder") or "INBOX")
    # imaplib quotes nothing: "[Gmail]/All Mail" has to arrive in quotes.
    status, data = box.select(quote(folder), readonly=readonly)
    if status != "OK":
        box.logout()
        raise RuntimeError(f"The folder {folder!r} does not exist on the server: {data}")
    return folder

def quote(text):
    return '"' + str(text).replace("\\", "").replace('"', "") + '"'

raw_uids = inp.get("uids")
if isinstance(raw_uids, (str, int)):
    raw_uids = [raw_uids]
uids = [str(u).strip() for u in (raw_uids or []) if str(u).strip()]
if not uids:
    raise ValueError("uids is empty; give the uids mail-fetch returned.")
if not all(u.isdigit() for u in uids):
    raise ValueError("uids are numbers, as mail-fetch returns them.")
seen = inp.get("seen")
move_to = str(inp.get("move_to") or "").strip()
label = str(inp.get("label") or "").strip()
if seen is None and not move_to and not label:
    raise ValueError("Nothing to do: give seen, move_to or label.")
box = connect()
try:
    folder = select(box, readonly=False)
    spec = ",".join(uids)
    if seen is not None:
        status, data = box.uid("STORE", spec, ("+" if seen else "-") + "FLAGS.SILENT", "(\\Seen)")
        if status != "OK":
            raise RuntimeError(f"The server refused the flag change: {data}")
    if label:
        status, data = box.uid("STORE", spec, "+X-GM-LABELS", "(" + quote(label) + ")")
        if status != "OK":
            raise RuntimeError(f"The server refused the label {label!r} (labels are a Gmail feature): {data}")
    if move_to:
        # Gmail has no Archive folder: archiving is moving to All Mail, which
        # takes the Inbox label off (measured 2026-10-06: MOVE "Archive"
        # answers [TRYCREATE], and STORE -X-GM-LABELS (\Inbox) answers OK
        # while the message stays in the inbox). All Mail is found by its
        # \All attribute, so a mailbox in another language works too.
        target = move_to
        if move_to.lower() == "archive" and "X-GM-EXT-1" in box.capabilities:
            status, folders = box.list()
            for entry in folders or []:
                line = entry.decode() if isinstance(entry, bytes) else str(entry)
                if "\\All" in line.split('"')[0]:
                    target = line.rsplit('"', 2)[1]
            if target == move_to:
                raise RuntimeError("The server has no All Mail folder to archive into.")
        status, data = box.uid("MOVE", spec, quote(target))
        if status != "OK":
            raise RuntimeError(f"The folder {move_to!r} does not exist on the server: {data}")
finally:
    try:
        box.logout()
    except Exception:
        pass

out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out["marked"] = len(uids)
out["uids"] = uids
out["folder"] = folder

Tests

Requires: python:3.9

  • seen

    Two unread messages are marked read.

  • label-and-move

    A label is added and the message moves to a folder that exists.

  • archive-on-gmail

    On a server with Gmail's extension, Archive moves to the folder LIST marks \All.

  • bad-folder

    A folder the server lacks fails the step and names it.

  • no-uids

    An empty uid list is refused before connecting.

  • nothing-to-do

    No change asked for is refused before connecting.