Back to skills
SkillHub ClubShip Full StackFull Stack

gmail-summarize

Fetch recent unread Gmail (yesterday + today) and send a digest to the user.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3,114
Hot score
99
Updated
March 20, 2026
Overall rating
C0.0
Composite score
0.0
Best-practice grade
B80.4

Install command

npx @skill-hub/cli install openclaw-skills-gmail-summarize

Repository

openclaw/skills

Skill path: skills/2p1c/gmail-summarize

Fetch recent unread Gmail (yesterday + today) and send a digest to the user.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: openclaw.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install gmail-summarize into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/openclaw/skills before adding gmail-summarize to shared team environments
  • Use gmail-summarize for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: gmail-summarize
description: Fetch recent unread Gmail (yesterday + today) and send a digest to the user.
metadata: {
  "skill": {
    "emoji": "๐Ÿ“ฌ",
    "requires": {
      "runtime": "python3",
      "env_vars": {
        "required": ["IMAP_USERNAME", "IMAP_PASSWORD"],
        "optional": ["IMAP_HOST", "IMAP_PORT", "IMAP_MAX_BODY_CHARS", "EMAIL_CONFIG_PATH"]
      },
      "credentials": [
        "IMAP_USERNAME (env, required) โ€” IMAP login username",
        "IMAP_PASSWORD (env, required) โ€” IMAP login password / app-password",
        "IMAP_HOST    (env, optional, default: imap.gmail.com)",
        "IMAP_PORT    (env, optional, default: 993)"
      ],
      "config": "$EMAIL_CONFIG_PATH or ~/.config/gmail-summarize/config.json (used only when IMAP_USERNAME/IMAP_PASSWORD env vars are absent)",
      "config_fields": ["email.imapHost", "email.imapPort", "email.imapUsername", "email.imapPassword"],
      "external_connections": ["IMAP server specified in IMAP_HOST or email.imapHost"]
    }
  }
}
---

# Gmail Recent Digest

## Requirements
- **Python 3** must be available in the container environment (`python` command).
- IMAP credentials must be supplied via **one** of the following methods (env vars take precedence):

### Option A โ€” Environment variables (recommended)

| Variable | Description | Default |
|---|---|---|
| `IMAP_HOST` | IMAP server hostname | `imap.gmail.com` |
| `IMAP_PORT` | IMAP server port | `993` |
| `IMAP_USERNAME` | IMAP login username | *(required)* |
| `IMAP_PASSWORD` | IMAP login password / app-password | *(required)* |
| `IMAP_MAX_BODY_CHARS` | Max body characters per email | `2000` |

### Option B โ€” Config file

Set `EMAIL_CONFIG_PATH` to point to a JSON file, or place the file at `~/.config/gmail-summarize/config.json`.
The file must contain **only** the fields below (no other sensitive data should be stored in this file):

```json
{
  "email": {
    "imapHost": "imap.gmail.com",
    "imapPort": 993,
    "imapUsername": "[email protected]",
    "imapPassword": "your-app-password",
    "maxBodyChars": 2000
  }
}
```

## Security notes
- **Preferred**: supply credentials via environment variables (`IMAP_HOST`, `IMAP_PORT`, `IMAP_USERNAME`, `IMAP_PASSWORD`) so no file on disk is read at all.
- When a config file is used, it should contain **only** the `email` fields listed above. The script reads only those four fields and nothing else from the file.
- The only external connection made is to the IMAP server declared in `IMAP_HOST` / `email.imapHost`. No other endpoints are contacted.
- Credentials are used solely to authenticate the IMAP session and are not logged or stored elsewhere by this skill.

## When to use
- User asks "check my Gmail", "summarize my emails", "้‚ฎไปถๆ‘˜่ฆ"
- Cron trigger message contains "gmail_digest"

## Workflow
1. Run the fetch script via exec tool:
   `python {workspace}/skills/gmail-summarize/scripts/fetch_unseen.py`
   (replace {workspace} with your actual workspace root)
2. Parse the JSON array. Each item has: sender, subject, date, body
3. For each email compose one line:
   `[date] sender | subject โ€” one-sentence body summary`
4. Send the full digest via MessageTool in this format:

   ๐Ÿ“ฌ **้‚ฎไปถๆ‘˜่ฆ** (Nๅฐ๏ผŒ่ฆ†็›– MM/DDโ€“MM/DD)

   โ€ข [ๆ—ฅๆœŸ] ๅ‘ไปถไบบ | ไธป้ข˜ โ€” ไธ€ๅฅ่ฏๆ‘˜่ฆ
   โ€ข [ๆ—ฅๆœŸ] ๅ‘ไปถไบบ | ไธป้ข˜ โ€” ไธ€ๅฅ่ฏๆ‘˜่ฆ
   ...

5. If result is empty, send: ๐Ÿ“ญ ่ฟ‘ไธคๆ—ฅๆš‚ๆ— ๆœช่ฏป้‚ฎไปถ

## Output Rules
- Send the digest message ONLY. Do NOT add any extra comments, greetings, explanations, or follow-up questions before or after the digest.
- Do NOT say things like "ไธปไบบ๏ผŒไปฅไธ‹ๆ˜ฏๆ‚จ็š„้‚ฎไปถๆ‘˜่ฆ" or "ๅฆ‚้œ€ไบ†่งฃ่ฏฆๆƒ…่ฏทๅ‘Š็Ÿฅ" etc.
- The digest message itself is the complete and final response.
- The one-sentence body summary MUST be translated into Chinese. Sender names and subjects should keep their original text as-is.


---

## Skill Companion Files

> Additional files collected from the skill directory layout.

### _meta.json

```json
{
  "owner": "2p1c",
  "slug": "gmail-summarize",
  "displayName": "Gmail-digester",
  "latest": {
    "version": "1.1.4",
    "publishedAt": 1772790533822,
    "commit": "https://github.com/openclaw/skills/commit/a4c8445a0d2d482ae40dae7db5d73786464d1ea1"
  },
  "history": []
}

```

### scripts/fetch_unseen.py

```python
#!/usr/bin/env python3
"""Fetch unread Gmail from yesterday and today, print as JSON."""
import html as _html
import imaplib
import json
import os
import re
from datetime import date, datetime, timedelta
from email import policy
from email.header import decode_header, make_header
from email.parser import BytesParser
from email.utils import parseaddr, parsedate_to_datetime
from pathlib import Path

# โ”€โ”€ Config โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
# Priority 1: individual environment variables (no file read needed)
_env_host     = os.environ.get("IMAP_HOST", "").strip()
_env_port     = os.environ.get("IMAP_PORT", "").strip()
_env_username = os.environ.get("IMAP_USERNAME", "").strip()
_env_password = os.environ.get("IMAP_PASSWORD", "").strip()
_env_maxchars = os.environ.get("IMAP_MAX_BODY_CHARS", "").strip()

if _env_username and _env_password:
    # Credentials supplied entirely via env โ€” no config file required
    IMAP_HOST = _env_host or "imap.gmail.com"
    IMAP_PORT = int(_env_port) if _env_port else 993
    USERNAME  = _env_username
    PASSWORD  = _env_password
    MAX_CHARS = min(int(_env_maxchars) if _env_maxchars else 2000, 2000)
else:
    # Priority 2: config file (EMAIL_CONFIG_PATH or default location)
    # The config file should contain ONLY the email fields listed in SKILL.md.
    config_path_env = os.environ.get("EMAIL_CONFIG_PATH", "").strip()
    if config_path_env:
        config_path = Path(config_path_env).expanduser()
    else:
        config_path = Path.home() / ".config" / "gmail-summarize" / "config.json"
    cfg = json.loads(config_path.read_text())
    email_cfg = cfg.get("email", {})

    IMAP_HOST = _env_host     or email_cfg.get("imapHost", "imap.gmail.com")
    IMAP_PORT = int(_env_port) if _env_port else int(email_cfg.get("imapPort", 993))
    USERNAME  = _env_username or email_cfg.get("imapUsername", "")
    PASSWORD  = _env_password or email_cfg.get("imapPassword", "")
    MAX_CHARS = min(int(_env_maxchars) if _env_maxchars else int(email_cfg.get("maxBodyChars", 2000)), 2000)

# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
IMAP_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun",
               "Jul","Aug","Sep","Oct","Nov","Dec"]

def fmt_imap_date(d: date) -> str:
    return f"{d.day:02d}-{IMAP_MONTHS[d.month-1]}-{d.year}"

def decode_hdr(v: str) -> str:
    try:
        return str(make_header(decode_header(v or "")))
    except Exception:
        return v or ""

def html_to_text(h: str) -> str:
    h = re.sub(r"<br\s*/?>", "\n", h, flags=re.I)
    h = re.sub(r"</p>", "\n", h, flags=re.I)
    h = re.sub(r"<[^>]+>", "", h)
    return _html.unescape(h)

def extract_body(msg) -> str:
    if msg.is_multipart():
        plains, htmls = [], []
        for part in msg.walk():
            if part.get_content_disposition() == "attachment":
                continue
            ct = part.get_content_type()
            try:
                payload = part.get_content()
            except Exception:
                b = part.get_payload(decode=True) or b""
                payload = b.decode(part.get_content_charset() or "utf-8", errors="replace")
            if not isinstance(payload, str):
                continue
            if ct == "text/plain":
                plains.append(payload)
            elif ct == "text/html":
                htmls.append(payload)
        if plains:
            return "\n\n".join(plains).strip()
        if htmls:
            return html_to_text("\n\n".join(htmls)).strip()
        return ""
    try:
        payload = msg.get_content()
    except Exception:
        b = msg.get_payload(decode=True) or b""
        payload = b.decode(msg.get_content_charset() or "utf-8", errors="replace")
    if not isinstance(payload, str):
        return ""
    if msg.get_content_type() == "text/html":
        return html_to_text(payload).strip()
    return payload.strip()

# โ”€โ”€ Date range โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
today     = date.today()
yesterday = today - timedelta(days=1)
tomorrow  = today + timedelta(days=1)

# Python-side cutoff: yesterday 00:00:00 local time (naive)
cutoff_dt = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0)

# โ”€โ”€ Fetch via IMAP โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
client = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
client.login(USERNAME, PASSWORD)
client.select("INBOX")

# Use SINCE one day earlier as a wide net to compensate for UTC offset,
# then filter precisely on the Python side.
# X-GM-RAW restricts results to Gmail's Primary category only.
wide_since = yesterday - timedelta(days=1)
_, data = client.search(None,
    "SINCE", fmt_imap_date(wide_since),
    "BEFORE", fmt_imap_date(tomorrow),
    "UNSEEN",
    "X-GM-RAW", "category:primary",
)
ids = data[0].split() if data and data[0] else []

results = []
for imap_id in ids:
    _, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
    if not fetched:
        continue
    raw = next((bytes(x[1]) for x in fetched if isinstance(x, tuple) and len(x) >= 2), None)
    if not raw:
        continue

    parsed = BytesParser(policy=policy.default).parsebytes(raw)
    sender  = parseaddr(parsed.get("From", ""))[1].strip().lower()
    subject = decode_hdr(parsed.get("Subject", ""))
    date_str = parsed.get("Date", "")
    body    = extract_body(parsed)[:MAX_CHARS] or "(empty)"

    # โ”€โ”€ Python-side date filter (precise, timezone-aware) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    try:
        msg_dt = parsedate_to_datetime(date_str)          # aware datetime
        msg_local = msg_dt.astimezone().replace(tzinfo=None)  # convert to local naive
        if msg_local < cutoff_dt:
            continue  # older than yesterday 00:00 local โ†’ skip
    except Exception:
        pass  # if date unparseable, include it rather than silently drop

    results.append({
        "sender":  sender,
        "subject": subject,
        "date":    date_str,
        "body":    body,
    })

client.logout()
output = json.dumps(results, ensure_ascii=False, indent=2)
# Guard: exec tool has a 10,000-char output limit; truncate gracefully if needed.
if len(output) > 9000:
    output = json.dumps(results, ensure_ascii=False, separators=(",", ":"))
print(output)

```

gmail-summarize | SkillHub