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.
Inputs
| Field | Type | Description |
|---|---|---|
| imap_host | string | The IMAP server; imap.gmail.com for Gmail. |
| imap_port | integer | The port: 993 over SSL. |
| use_ssl | boolean | Connect over SSL; off only for a local stand-in. |
| usernamerequired | string | The mailbox address, which is the login name on Gmail. |
| mail_password | string | The mailbox password, an app password on Gmail, iCloud or Yahoo; reference the stored secret mail-password, never the password itself. |
| gmail_app_password | string | The 0.1.x name of mail_password, still read; leave it out. |
| folder | string | The folder to work in. |
| uidsrequired | Not declared | The uids to change, as mail-fetch returns them. |
| seen | boolean | True marks them read, false unread; leave it out to keep the flag. |
| move_to | string | The folder to move them to; on Gmail a label such as Archive. |
| label | string | A Gmail label to add, kept beside the folder. |
Outputs
| Field | Type | Description |
|---|---|---|
| markedrequired | integer | How many messages were changed. |
| uidsrequired | array | The uids changed. |
| folderrequired | string | The 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"] = folderTests
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.