/packs / infomaniak-mail

infomaniak-mail

Infomaniak mail, in the workspace: mail searched and read, drafts written, nothing sent.

  • integration
  • sources
  • Infomaniak Mail

As of yardr-packs at commit a2b86e6, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member, merges · lands.

01 / apply it

yardr pack apply ./infomaniak-mail

From a clone of yardr-packs: the pack's directory. yardr pack check ./infomaniak-mail shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/infomaniak-mail/

From this site, as the files below.

Lives in infomaniak-mail/ at a2b86e6 · SETUP.md

02 / the files

Every file, as it ships.

# Infomaniak mail as a pack of kind source: the integration, imported into
# the yard and into no depot. What Infomaniak mail is here: the declared
# source infomaniak-mail, the twin of google-mail. It lists the mailboxes
# and folders, searches and reads mail, lists drafts, and writes drafts, and
# sends nothing. Its one update, draft, builds its request body with one
# program the pack brings beside its declaration, build-draft, which is the
# only place that says what the request does with the mail it is given.
#
#   yardr pack apply /Users/mckean/projects/repos/yardr-packs/infomaniak-mail
#
# The source is named infomaniak-mail: a pack is named after the service it
# talks to. A pack of kind source asks no grants at import, so what a crew
# member may call is granted afterwards, by the setup notes. Every agent
# granted a call acts as the one token's user: it reaches every mailbox that
# user may open, whatever it asks for.
name: infomaniak-mail
kind: source
setup: SETUP.md

sources:
  infomaniak-mail:
    declaration: infomaniak-mail.yaml

# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and a
# terminal pack apply ignores them. A preset names read, every read call, or
# a call by its name: no word stands for every update, so drafts names draft.
presets:
  read:
    about: Read mailboxes, folders, mail and drafts; write nothing
    grants:
      infomaniak-mail: [read]
  drafts:
    about: Read mail, and write drafts the person sends
    grants:
      infomaniak-mail: [read, draft]
#!/usr/bin/env python3
# build-draft: the request body of the draft call of the infomaniak-mail
# source. The yard runs it for each draft (the call's build in
# infomaniak-mail.yaml): the call's parameters are on stdin as {"params":
# {...}}, and one JSON object, the message the service wants, goes to stdout.
# A refusal is one line on stderr and a non-zero exit, and nothing is sent.
# It makes no request and reads no credential.
#
# Why a program and not a body template: the service takes a draft as one
# whole message, HTML for its text and each recipient an object, and a
# template can neither escape a text into HTML nor leave a missing cc as null.
# The message is the one createDraft of Infomaniak's own server builds
# (github.com/Infomaniak/mcp-server-mail, src/mail-client.ts, read 10 November
# 2026), field for field and in its order, for the same arguments.
#
# The one thing that must hold: what tells a draft from a mail sent is the
# message's "action", "save" or "send" (the server sends a draft by the same
# message with "send"). Here "action" is written below as "save", nothing
# reads it from the parameters, and every message is built from the
# parameters listed in PARAMS: a key the call does not declare is refused, not
# passed on. The pack's test holds each message to action save.
#
# Where the service decides and this file does not: the sender. The service
# builds the From header from the account's default sending identity, and
# ignores the display name here, so identity_id is null, as the server sends it
# where it finds no identity. The "from" parameter is the mailbox's own address
# as `mailboxes` shows it, because the message names one.
#
# Needs Python 3.9 or newer, and nothing beyond its standard library.
import json
import sys

# The parameters of the call, and which must be given. mailbox is the path's:
# it is in the request's address, and no part of the message.
PARAMS = {"mailbox", "from", "to", "cc", "subject", "body", "in_reply_to", "in_reply_to_uid", "references"}
REQUIRED = ("from", "to", "subject", "body")

# A header is one line: these are the fields the service writes into one.
ONE_LINE = ("from", "to", "cc", "subject", "in_reply_to", "in_reply_to_uid", "references")

# The server's escapeHtml, one pass over the text: a replacement is never
# escaped again.
ESCAPES = str.maketrans({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"})
STYLE = "font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;"


def refuse(words):
    sys.stderr.write("build-draft: %s\n" % words)
    sys.exit(1)


def html(text):
    return '<html><body><div style="%s">%s</div></body></html>' % (STYLE, text.translate(ESCAPES).replace("\n", "<br>"))


def recipients(address):
    # The server's parseRecipients splits a comma-separated string; the call
    # takes one address for each of to and cc, as google-mail's draft does.
    return [{"name": "", "email": address}] if address else None


def build(params):
    for key in sorted(params):
        if key not in PARAMS:
            refuse("%s is not a parameter of a draft" % key)
        if not isinstance(params[key], str):
            refuse("%s is no text" % key)
    missing = [key for key in REQUIRED if key not in params]
    if missing:
        refuse("a draft needs %s" % ", ".join(missing))
    for key in ONE_LINE:
        if "\n" in params.get(key, "") or "\r" in params.get(key, ""):
            refuse("%s holds a line break: a header is one line" % key)
    sender = params["from"]
    name = sender.split("@")[0]
    return {
        "uuid": None,
        "subject": params["subject"],
        "body": html(params["body"]),
        "quote": None,
        "mime_type": "text/html",
        "from": {"id": None, "name": name, "email": sender},
        "reply_to": {"name": name, "email": sender},
        "to": recipients(params["to"]),
        "cc": recipients(params.get("cc")),
        "bcc": None,
        "references": params.get("references", ""),
        "in_reply_to": params.get("in_reply_to") or None,
        "in_reply_to_uid": params.get("in_reply_to_uid") or None,
        "forwarded_uid": None,
        "attachments": [],
        "identity_id": None,
        "ack_request": False,
        "st_uuid": None,
        "uid": None,
        "resource": None,
        "priority": "normal",
        "encrypted": False,
        "encryption_password": "",
        "event_poll_uuid": None,
        "action": "save",
        "delay": 0,
    }


def main():
    try:
        request = json.loads(sys.stdin.buffer.read())
    except ValueError:
        refuse("stdin is no JSON")
    params = request.get("params") if isinstance(request, dict) else None
    if not isinstance(params, dict):
        refuse('stdin is no {"params": {...}}')
    sys.stdout.write(json.dumps(build(params), separators=(",", ":")) + "\n")


if __name__ == "__main__":
    main()
# Infomaniak mail as a declared source: the twin of google-mail. It lists
# the mailboxes and their folders, searches and reads mail, lists drafts, and
# writes drafts. What it never does is send: no draft is sent, and nothing is
# marked, flagged, moved, archived or deleted, in a mailbox or in a folder.
# Sending stays the person's; the yard writes the draft, the person presses
# send. The call names are google-mail's where the service has the call, and
# its arguments and answers are where the service allows; the service's own
# ids (a mailbox, a folder) and filters are arguments beside them.
#
# These calls are Infomaniak's webmail backend. developer.infomaniak.com does
# not document them and they may change without notice: they are taken from
# Infomaniak's own server, github.com/Infomaniak/mcp-server-mail, read on 10
# November 2026 at main 392ee8a (6 November, package 2.1.0): README.md,
# src/index.ts, src/mail-client.ts and tests/mail-client.test.js, whose
# mocked answers are the shapes this pack's fixtures follow. What was read:
#   - Every answer is {result, data}, so every call keeps both.
#   - GET /api/mailbox?with=aliases,permissions,accountId,count_users lists
#     the mailboxes, each with uuid, email, mailbox, is_primary, hosting_id
#     and aliases; the server takes the first as its primary. A mailbox's
#     uuid is in every other path: /api/mail/{uuid}/...
#   - GET /mail/{uuid}/folder?with=ik-static is the folder tree: id, name,
#     separator, role (INBOX, DRAFT, SENT, ARCHIVE, TRASH), unread_count,
#     total_count and children.
#   - GET /mail/{uuid}/folder/{folder}/message lists a folder's mail:
#     offset, limit, thread=on (threads) or off (messages) and severywhere=0
#     or 1. With 1 the search runs in every folder and the path's folder only
#     anchors it (the server gives its INBOX). The terms are scontains (body
#     and metadata), sfrom, sto, ssubject, and sfromdate and stodate, each
#     as "2026-06-01 00:00:00". The answer is data.threads: uid, subject,
#     from, to, date, messages_count, unseen_messages, and messages with
#     uid ("<number>@<folder id>"), preview, folder_id and folder.
#   - GET /mail/{uuid}/folder/{folder}/message/{number} reads one message,
#     with prefered_format=html (the service's spelling) and with=
#     auto_uncrypt,thread_context: uid, msg_id, subject, from, to, cc, bcc,
#     date, body, html, preview, attachments, seen, flagged, folder, headers.
#   - POST /mail/{uuid}/draft writes a draft from one whole message whose
#     action is "save", and answers {uuid, uid}. The server sends a draft by
#     a PUT to /mail/{uuid}/draft/{draft uuid} with the same message and
#     action "send". The action is what tells a draft from a mail sent, and
#     whether a POST with "send" would send too was not tried, so it is
#     treated as if it would: build-draft, beside this file, is the one place
#     that writes the message, sets save and nothing else, and takes no other
#     argument; the pack's test holds every draft to it.
#
# Left out, because the service has no counterpart: thread, a thread read
# whole (the server lists a folder's threads and reads one message, which
# search and message do here; message asks the service for its thread_context
# and keeps whatever it answers, which is untried), and draft_edit, a draft
# edited from its id (a PUT to /draft/{uuid} replaces the whole message, and
# the server keeps each draft's state in its own memory to do it; to build
# that here would take a read first, and a request built from several is no
# call of this file). A draft that changed is written again, and the old one
# stays in Drafts, which no call here deletes. Also left out: raw, bcc, a
# thread to answer by its id (a reply takes the three headers message
# shows), attachments, the sending identity, contacts, folders made or
# removed, and everything that moves, marks or deletes mail.
#
# Not tried: no request was made to mail.infomaniak.com, because there was no
# account to make one with. Every call was run through the real yardr against
# a stand-in that answers the server's own mocks (README.md, "What was
# checked"). Where this file and the service differ, the service is right.
#
# The service takes 60 requests a minute. A token made in the manager with the
# scope workspace:mail opens every mailbox of its user, and one token does
# both the reads and the draft: Infomaniak scopes it by product, not by
# read and write.
#
# Infomaniak takes no idempotency key, so the file names none: a draft tried
# again after a lost answer is written twice.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: infomaniak-mail
about: Infomaniak mail read and drafts. Lists mailboxes and folders, searches and reads mail, lists drafts, and writes drafts; sends nothing.

# The name of the variable, never the value. A token made in the manager with
# the scope workspace:mail, sent as a Bearer token and typed unseen at setup.
credential:
  env: INFOMANIAK_MAIL_TOKEN
  header: "Authorization: Bearer {credential}"

# base is the webmail's API. The mailbox is a path of each call, not a part
# of base, because the call that finds it, mailboxes, is not under one.
base: https://mail.infomaniak.com/api
# mailboxes needs no argument and shows at once that the token is taken.
check: mailboxes
calls:
  mailboxes:
    kind: read
    about: List the mailboxes the token opens, each with its uuid (the mailbox every other call takes), its address and its aliases
    request:
      method: GET
      path: /mailbox
      query: { with: "aliases,permissions,accountId,count_users" }
    answer: { select: [result, data] }
  folders:
    kind: read
    about: List a mailbox's folders, each with its id and its role (INBOX, DRAFT, SENT, ARCHIVE, TRASH)
    params:
      mailbox: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$", max: 64, log: true }   # a uuid as mailboxes gives it
    request:
      method: GET
      path: /mail/{mailbox}/folder
      query: { with: "ik-static" }
    answer: { select: [result, data] }
  search:
    kind: read
    about: Search mail, one page of at most 50
    params:
      mailbox: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$", max: 64, log: true }
      # The folder is in the path. With everywhere 1 it only anchors the search
      # in every folder: give the INBOX's id, as the server does.
      folder: { type: string, pattern: "^[A-Za-z0-9_=+/-]{1,512}$", max: 512, log: true }   # an id as folders gives it
      q: { type: string, max: 1000, optional: true }   # free text in the body and the headers, no syntax; the service's scontains
      from: { type: string, max: 254, optional: true }   # an address or a name in From
      to: { type: string, max: 254, optional: true }   # an address or a name in To
      subject: { type: string, max: 500, optional: true }
      since: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", optional: true }   # a date and a time as the service takes them, 2026-06-01 00:00:00
      before: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", optional: true }
      everywhere: { type: enum, values: ["0", "1"], default: "1" }   # 1 searches every folder, 0 only the folder given
      maxResults: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$" }   # 1 to 50, always given, as google-mail's
      offset: { type: string, pattern: "^[0-9]{1,6}$", default: "0" }   # how many results to skip: the next page of a search is offset=maxResults
    request:
      method: GET
      path: /mail/{mailbox}/folder/{folder}/message
      query: { offset: "{offset}", thread: "off", severywhere: "{everywhere}", limit: "{maxResults}", scontains: "{q}", sfrom: "{from}", sto: "{to}", ssubject: "{subject}", sfromdate: "{since}", stodate: "{before}" }
    # Each message of data.threads has its uid, "<number>@<folder id>": message takes the number and the folder id.
    answer: { select: [result, data], max: 262144 }
  message:
    kind: read
    about: Read one message, with its headers and its text
    params:
      mailbox: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$", max: 64, log: true }
      folder: { type: string, pattern: "^[A-Za-z0-9_=+/-]{1,512}$", max: 512, log: true }   # the message's own folder_id as search shows it, which is not always the folder searched
      id: { type: string, pattern: "^[0-9]{1,12}$", max: 12, log: true }   # the number before the @ in a message's uid
    request:
      method: GET
      path: /mail/{mailbox}/folder/{folder}/message/{id}
      query: { prefered_format: "html", with: "auto_uncrypt,thread_context" }
    answer: { select: [result, data], max: 1048576 }   # the HTML and the text both come; an answer over the bound is refused, not cut
  drafts:
    kind: read
    about: List drafts, one page of at most 50
    params:
      mailbox: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$", max: 64, log: true }
      # The folder whose role folders shows as DRAFT. The request is a folder's
      # list, so it takes any folder's id; a grant of this call is a grant to
      # list any folder's threads, with their previews.
      folder: { type: string, pattern: "^[A-Za-z0-9_=+/-]{1,512}$", max: 512, log: true }
      maxResults: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$" }
      offset: { type: string, pattern: "^[0-9]{1,6}$", default: "0" }
    request:
      method: GET
      path: /mail/{mailbox}/folder/{folder}/message
      query: { offset: "{offset}", thread: "on", severywhere: "0", limit: "{maxResults}" }
    answer: { select: [result, data], max: 262144 }   # a draft is a thread of one message: its uid is in data.threads[].messages[]
  draft:
    kind: update
    about: Write a draft, never send one
    params:
      mailbox: { type: string, pattern: "^[A-Za-z0-9_-]{1,64}$", max: 64, log: true }
      # The mailbox's own address as mailboxes shows it, or one of its aliases.
      # The service sends from its default identity whichever is given; the
      # message names one.
      from: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, log: true }
      to: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, log: true }   # one address, as google-mail's
      cc: { type: string, pattern: "^[^@[:space:]]+@[^@[:space:]]+$", max: 254, optional: true, log: true }   # one address beside to, or none
      subject: { type: string, max: 500, pattern: '^[^\r\n]*$' }   # free text, one line
      body: { type: string, max: 100000 }   # plain text; the program makes the HTML
      # To answer a message: its msg_id, its headers' references with its
      # msg_id added, and its uid, all as message shows them. A draft that
      # names none is a new mail.
      in_reply_to: { type: string, max: 998, pattern: "^[^[:space:]]+$", optional: true }
      references: { type: string, max: 4000, pattern: '^[^\r\n]*$', optional: true }
      in_reply_to_uid: { type: string, max: 600, pattern: "^[0-9]+@[A-Za-z0-9_=+/-]+$", optional: true }
    request:
      method: POST
      path: /mail/{mailbox}/draft
    build: [build-draft]
    answer: { select: [result, data] }   # data holds the draft's uuid and uid
# infomaniak-mail

Infomaniak mail, as a twin of the `google-mail` source. A yard can list a mailbox's folders, search and read mail, list drafts and write drafts, as `yardr call infomaniak-mail ...`. The pack sends no mail. It moves, marks, flags and deletes none.

**Not tried against Infomaniak.** The pack was built from the source of Infomaniak's own MCP server and run against a stand-in. No request has been made to mail.infomaniak.com, because there was no account to make one with. "What was checked" lists what that leaves open, and the first call against a live mailbox is the real check.

The calls are the webmail's backend, not Infomaniak's documented API (developer.infomaniak.com does not list them). Infomaniak's MCP server uses them, and Infomaniak may change them without notice.

The pack declares the source and carries one program, `build-draft`, which builds the body of a draft. It needs Python 3.9 or newer on the `PATH` of the yard's server, and nothing else. [SETUP.md](SETUP.md) lists what the operator sets up. `.yardr/test` runs `yardr pack check` and this pack's `test`.

| file | what it is |
| --- | --- |
| `pack.yaml` | the pack: kind `source`, the source it declares, two suggested presets (`read`, `drafts`) |
| `infomaniak-mail.yaml` | the source: Infomaniak's base, one bearer token, six calls |
| `build-draft` | the program: the body of a `draft`, parameters on stdin, one JSON message out |
| `SETUP.md` | what the operator does: the token, the pack, the grants, a check, a first call |
| `test`, `testdata/` | the pack's tests, a stand-in for the service, its fixtures and the goldens |

## The calls

`read` grants the five reads together. `draft` is the one update and is granted by its name. The yard shows each answer as data from outside the yard: mail is whatever anyone sent the account, so it carries no instruction.

| call | kind | takes | answers |
| --- | --- | --- | --- |
| `mailboxes` | read | nothing | `data`: each mailbox with its `uuid`, `email`, `mailbox`, `is_primary` and `aliases`. The source's check call. |
| `folders` | read | `mailbox` | `data`: the folder tree, each folder with its `id`, `name`, `role`, counts and `children`. |
| `search` | read | `mailbox`, `folder`, `maxResults`; `q`, `from`, `to`, `subject`, `since`, `before`, `everywhere`, `offset` | `data.threads`: one entry for each message found. |
| `message` | read | `mailbox`, `folder`, `id` | `data`: the message, with its headers, its text and its HTML. |
| `drafts` | read | `mailbox`, `folder`, `maxResults`; `offset` | `data.threads`: the mail of the folder named. |
| `draft` | update | `mailbox`, `from`, `to`, `subject`, `body`; `cc`, `in_reply_to`, `references`, `in_reply_to_uid` | `data`: the draft's `uuid` and `uid`. |

Every answer is `{result, data}`, as the service gives it, and each call keeps both. An answer over the call's bound is refused, not cut: 1 MiB for `message`, 256 KB for `search` and `drafts`, 64 KB for the rest. A smaller `maxResults` brings a search under its bound.

### How the ids go

Infomaniak names a message by three things, where Gmail names it by one. `mailboxes` gives the mailbox's `uuid`, which is in every other address. `folders` gives each folder's `id`, which is in the address of every search and read. `search` gives each message's `uid`, which reads `<number>@<folder id>`: `message` takes the folder id and the number. The mailbox is found by that call and not typed at setup, so the declaration is the same for everyone and an account with several mailboxes works.

A folder id looks like base64 of the folder's path, compressed: the server's tests show `eJwLTs0rAQADzwGb` for `Sent`, which decodes and inflates to `Sent`. So it can hold `/`, `+` and `=`, and the patterns allow them. Give it as it stands: yardr sends a `/` in it as `%2F`.

### `search`

`q` is the service's `scontains`: free text in the body and the headers, with none of Gmail's syntax. `from`, `to` and `subject` filter on the headers. `since` and `before` take a date and a time as the service does, `2026-06-01 00:00:00`. `maxResults` is 1 to 50 and always given, as in `google-mail`. `offset` is how many results to skip, so the next page is `offset=<maxResults>`: `google-mail` leaves paging out, and the service makes it one number.

`everywhere` is 1 unless given. The service puts the folder in the address and, with `severywhere=1`, searches every folder from it. The server gives its INBOX's id, so give that one. `everywhere=0` keeps the search to the folder given, and with no term at all it lists that folder's newest mail. A message found in another folder says so in its `folder_id`: read it from there.

The server refuses a search with none of `q`, `from`, `to`, `subject`, `since` and `before`. The pack does not: a declaration cannot say "one of". What the service answers to an unfiltered search across every folder is not known.

### `drafts`

The service lists a folder's mail by one request, so `drafts` is that request with `thread=on` for the folder named, which is meant to be the one whose role is `DRAFT`. It takes any folder's id. A grant of `drafts` is a grant to list any folder's threads with their previews, so grant it with `read` in mind.

### `draft`

A draft is `to`, `subject` and `body`, one address in `to` and at most one in `cc`, as `google-mail`'s. The program makes the HTML the service wants from the text: it escapes `& < > " '` and turns a newline into `<br>`, as the server does. `from` is the mailbox's own address as `mailboxes` shows it. The service sends from the account's default identity whichever address is given, and the display name is the account's. The pack sets no sending identity, because those come from another host (api.infomaniak.com) and the pack declares one.

To answer a message, give the three headers `message` shows: `in_reply_to` is its `msg_id`, `references` is its `headers.references` with its `msg_id` added at the end, and `in_reply_to_uid` is its `uid`, `<number>@<folder id>`. The server derives the first two from the third when they are missing, with a read of its own. A declared call cannot read first, so the caller does.

Infomaniak takes no idempotency key. A draft tried again after a lost answer is written twice.

## What `google-mail` has and this does not

| `google-mail` | here |
| --- | --- |
| `thread`, a thread read whole | **Left out.** The server has no call that reads one thread: it lists a folder's threads (`drafts` does that for any folder) and reads one message (`message`). `message` asks the service for its `thread_context` and keeps whatever it answers, which is untried. Building a thread from several requests in a program is not a call of this pack. |
| `draft_edit`, a draft replaced by its id | **Left out.** The service has no partial update: a `PUT` to `/draft/{uuid}` replaces the whole message, and the server keeps each draft's state in its own memory to do it. Here an edit would take a read first. A draft that changed is written again, and the old one stays in Drafts, which no call here deletes. |
| `format` of `message` | None. The service answers the whole message, HTML and text. |
| `q` of `drafts` | None. The server never sends search terms with `thread=on`, the way it lists drafts. |
| `thread` of `draft` | The three reply headers above. |
| `raw` | None. The service takes a JSON message, not RFC 2822. |

Also left out: `bcc`, more than one address in `to` or `cc`, attachments (an upload with an endpoint of its own), the sending identity, contacts, creating, renaming and deleting folders, and everything that sends, moves, marks, flags, archives or deletes mail. There is no mint: the token is long-lived. Nor is there an IMAP fallback (the mailbox over IMAP on `mail.infomaniak.com`, through a program like `proton`'s): it would read mail and write no draft, and it is worth a pack of its own only if these calls prove unstable.

## How it never sends

What tells a draft from a mail sent is the `action` in the message's body, `"save"` or `"send"`. The server sends a draft by the same message with `"send"`, in a `PUT` to `/draft/{uuid}`. Whether a `POST` with `"send"` would send too was not tried, so the pack treats the field as the one thing between a draft and a sent mail. Four things keep it to saving, and the test holds each:

1. The declaration has no call that sends, moves, marks, flags, archives or deletes. Its five reads are `GET`s, and its one update is the `POST` to the draft's address.
2. `build-draft` writes the message. It sets `action` to `"save"` and reads it from nothing, and it builds only from the parameters it lists. A parameter it does not list is refused.
3. yardr refuses an argument the call does not declare before the program runs, so `yardr call infomaniak-mail draft ... action=send` sends nothing.
4. The test runs every case against a stand-in, and every request it saw must be a `GET` of the mailboxes, the folders or a message, or a `POST` to a draft whose body says `save`.

What the pack cannot do is narrow the token. Infomaniak scopes a token by product, so a token with `workspace:mail` could send as well. The declaration is the fence, and a process that reads the env file (`~/.yardr/sources/infomaniak-mail.env`, mode 0600) as the same user could use the token as it likes. That is true of every source of the yard.

yardr gives `build-draft` the token in its environment, as it does to any build command. The program does not read it and makes no request, and the test fails if it imports anything that reaches a network or the environment.

## What was checked

- Infomaniak's MCP server, github.com/Infomaniak/mcp-server-mail, at `main` 392ee8a (6 November 2026, package 2.1.0), read on 10 November 2026: `README.md`, `src/index.ts`, `src/mail-client.ts` and `tests/mail-client.test.js`. From them come every address, query, body field and answer shape here, and the fixtures in `testdata/answers/`, which follow the mocked answers of the server's tests. The message `build-draft` makes is `createDraft`'s, field for field and in its order.
- `google-mail` in the yardr repository (`packs/google-mail`), for the names and arguments.
- The declaration and the program, through the real yardr in a scratch yard against a stand-in (`testdata/standin.py`): the pack applies, `source check` makes the check call with the token, each call is made with `yardr call`, and what each sends is in a golden.
- The cards SETUP.md names, in a scratch yard on 10 November 2026, asked as a user of the yard: `--secret infomaniak-mail` and `--run "source grant ..."` are accepted, and `--setup infomaniak-mail` is refused, because yardr sets up only a pack it embeds ("yardr embeds no pack of that name ... the operator's to apply in a terminal"). So a crew member cannot set this pack up with one card, as for `google-mail`: the operator applies it, and the token and each grant go on cards of their own.

**Not checked: a live Infomaniak.** The stand-in follows the server's mocks, not the service's answers. The first call against a live mailbox is the real check. Look at:

- whether the token is accepted and what status a wrong one gets. The fixtures' error bodies are `{"result": "error"}` and are not the service's.
- the `with` of `mailboxes` and `folders`, which yardr sends as `aliases%2Cpermissions%2CaccountId%2Ccount_users` where the server writes the commas bare.
- a folder id with a `/` or a `+`, which yardr sends as `%2F` and as it stands. The server writes an id raw into every address but its attachment download's, where it encodes it.
- a search across every folder, anchored on the INBOX's id, with and without a term, and with a time that is not midnight.
- the size of real answers. The service probably sends more fields than the mocks, so a `message` or a `search` can come near the bounds above.
- a draft: that the service accepts a `from` and a null `identity_id`, that the draft lands in Drafts and opens in the webmail, and that the reply headers thread it.

## Limits

Infomaniak allows 60 requests a minute, the figure this pack was asked to respect; it is not in the MCP server's source, and nothing here checked it. yardr's limits are per hour: one caller makes 60 calls of a source an hour, 10 of them updates. Raising `calls_per_hour` is the operator's to keep under the service's minute.

## The tests

Run `./test` in this directory, or `sh .yardr/test` from the repository, which also runs `yardr pack check`. The test needs `jq`, `python3` and `yardr`, and fails if any is missing instead of skipping. It needs no network and no secret, never touches Infomaniak, and takes a few seconds. It has two parts, cheapest first:

1. `build-draft` as a process, run as the yard runs it, over `testdata/build/*.in`. Each message it makes is held to the server's fields in the server's order and to `action: "save"`, and the text of the server's own escaping test must come out as that test expects. Its refusals are one line each.
2. The real yardr in a scratch yard. It applies the pack from this directory, reads the declaration back (`testdata/declaration.golden`), runs the grants SETUP.md teaches exactly as written, and makes every case of `testdata/calls/*.case` through `yardr call` against the stand-in, which answers the fixtures. A call the declaration should turn away shows in its golden as no request at all. The token is a made-up string, and the test holds it to the one file that keeps it.

`UPDATE_GOLDEN=1 ./test` writes the goldens again from what the programs print. Read the diff before you keep it.
# Setting up Infomaniak mail

The pack adds Infomaniak's mail to the yard as the declared source `infomaniak-mail`: an agent can list the mailboxes and their folders, search and read mail, list drafts and write drafts. It cannot send mail, and it cannot move, mark or delete any. It is the twin of `google-mail`. Infomaniak sends nothing to the yard, so there is no hook or webhook to set up.

**These calls are not Infomaniak's documented API.** They are the ones the webmail's backend takes, which Infomaniak's own MCP server uses, and Infomaniak may change them without notice. The pack was built from that server's source and run against a stand-in, never against a live account (README.md says what that means).

**The yard acts as one token's user.** Every agent granted a call of this source reaches every mailbox that user may open, whatever it asks for. Nobody is asked before a call is made, so the grant is the permission. Mail is whatever anyone wrote to the account, so the yard shows each answer as data from outside the yard, never as an instruction. The token itself is as wide as its scope: Infomaniak scopes it by product, so a token that reads mail could send it too. What keeps the yard from sending is that the source has no call that does, and that its one update, `draft`, can only save (README.md, "How it never sends").

You need:

- an Infomaniak account with a mailbox, and a token for it (step 1);
- `python3` 3.9 or newer on the `PATH` of the yard's server, with nothing installed beside it: `build-draft`, the one program of the pack, is a Python file that uses only its standard library;
- a yardr that runs a call's `build` command (`yardr docs integrations` has "A call may build its body with a command").

Commands below call the pack's directory (the `infomaniak-mail` directory of the yardr-packs checkout) `<pack dir>`.

1. **The token.** In the Infomaniak manager, open the API token list, `https://manager.infomaniak.com/v3/ng/accounts/token/list`, the page Infomaniak's mail MCP server names in its README. Make a token with the scope `workspace:mail` and no other. Keep the token where you can paste it in step 3, and nowhere else: not in a file, a command line, a card or a note. The pack's variable for it is `INFOMANIAK_MAIL_TOKEN`.

2. **The pack.** Read what applying it does, then apply it as the operator, in a terminal:
   ```sh
   yardr pack check <pack dir>
   ```
   ```sh
   yardr pack apply <pack dir>
   ```
   The plan says one program, `build-draft`, builds the body of the `draft` call, and the apply card says it is run with the credential in its environment and is free to make requests of its own. Read it first: it is one short Python file, and it makes no request and reads no variable. The source's address is `https://mail.infomaniak.com/api`, and the token goes to no other host.

   **There is no setup card for this pack.** A crew member sets up a pack that yardr embeds with one card (`yardr bead ask <bead> ... --setup <pack>`), and this pack is not embedded: tried in a scratch yard on 10 November 2026, yardr refuses `--setup infomaniak-mail` with "yardr embeds no pack of that name: a pack that is a directory, a URL or an earlier import's record is the operator's to apply in a terminal". So the apply above is the operator's. The token (step 3) and each grant (step 4) can still be asked for on a card by a crew member, one card each.

3. **The token, in the yard.** `yardr source key` asks for it and shows nothing as you type. The yard's server writes it into the source's env file, `~/.yardr/sources/infomaniak-mail.env`, with mode 0600. The value is never an argument or a command's variable, where every process of your user could read it:
   ```sh
   yardr source key infomaniak-mail
   ```
   A crew member who needs the token asks for it on a secret card, and you answer it at a terminal with `yardr bead answer <bead> <ask> --secret`, which reads the value the same way. The answer on the bead holds the name and never the value:
   ```sh
   yardr bead ask <bead> "Give the Infomaniak mail token" --secret infomaniak-mail --context "A token made in the manager with the scope workspace:mail; the pack's SETUP.md, step 1."
   ```

4. **Who may call what.** A pack of kind source asks no grants at import, so what a crew member may call is granted afterwards. `yardr pack show infomaniak-mail` prints the pack's two suggested presets:
   1. `read`: every read call, `mailboxes`, `folders`, `search`, `message` and `drafts`, and any read call the source is later given; it writes nothing.
   2. `drafts`: every read call, and `draft`, which writes drafts the person sends from Infomaniak's webmail.

   A pack applied from a directory takes a preset by the calls it names, as these lines do:
   ```sh
   yardr source grant infomaniak-mail read --crew yardmaster
   ```
   ```sh
   yardr source grant infomaniak-mail draft --crew yardmaster
   ```
   `draft` is an update, and no word grants every update, so it is granted by its name, and `read` never covers it. A grantee who comes later gets one card:
   ```sh
   yardr bead ask <bead> "Let the reviewers read Infomaniak mail" --run "source grant infomaniak-mail read --group reviewers" --context "The reviewers read mail to check a bead's threads; the card names each call it covers."
   ```
   One caller may make 60 calls of a source in an hour, 10 of them updates. An agent that reads a mailbox message by message can run out; raise the limit with `yardr source set infomaniak-mail calls_per_hour=<n>`. Infomaniak allows 60 requests a minute, the figure this pack was asked to respect (it is not in the MCP server's source, and nothing here checked it). yardr counts hours, not minutes, so a raised hourly limit is the operator's to keep under that. To take a grant back, run `yardr source revoke infomaniak-mail read --crew yardmaster`.

5. **Check it.** The source's check call is `mailboxes`, which needs no argument. It confirms the token is set and `build-draft` can be run, then asks Infomaniak for the mailboxes the token opens and prints them:
   ```sh
   yardr source check infomaniak-mail
   ```
   If the check fails, the status it prints is the service's refusal. The usual causes are a token that was mistyped or revoked, or one made without the scope `workspace:mail`. Give it again with step 3, or make a new one with step 1. Which status Infomaniak answers to a wrong token was not seen: the pack has never been run against it.

6. **Try it.** As the operator, who may make any call, or in a session that was granted the calls. The mailbox is not typed at setup: `mailboxes` finds it, and every other call takes it as `mailbox`.
   ```sh
   yardr call infomaniak-mail mailboxes
   ```
   Each mailbox has a `uuid`, which is the `mailbox` of every call below, and an `email`, which is the `from` of a draft. Then the folders, each with its `id` and its `role` (`INBOX`, `DRAFT`, `SENT`, `ARCHIVE`, `TRASH`):
   ```sh
   yardr call infomaniak-mail folders mailbox=<uuid>
   ```
   A folder id looks like base64 and can hold `/`, `+` and `=`. Give it as it stands. A search needs the id of a folder in its address, and searches every folder from it, so give the INBOX's:
   ```sh
   yardr call infomaniak-mail search mailbox=<uuid> folder=<the INBOX's id> q=invoice maxResults=5
   ```
   `from`, `to` and `subject` filter on the headers, `since` and `before` on the date (`"2026-06-01 00:00:00"`), `everywhere=0` keeps the search to the folder given, and `offset=` is how many results to skip. A result's `uid` reads `<number>@<folder id>`, and its `folder_id` is the folder the message is in, which may not be the one searched. A message is read by that folder and that number:
   ```sh
   yardr call infomaniak-mail message mailbox=<uuid> folder=<the result's folder_id> id=<the number before the @>
   ```
   The drafts are the mail of the folder whose role is `DRAFT`:
   ```sh
   yardr call infomaniak-mail drafts mailbox=<uuid> folder=<the Drafts folder's id> maxResults=5
   ```
   A draft takes the mailbox's own address as `from`, who it is to, its subject and its text. It is the person who presses send, in Infomaniak's webmail:
   ```sh
   yardr call infomaniak-mail draft mailbox=<uuid> from=<the mailbox's email> to=someone@example.com subject=hello body="The body."
   ```
   To answer a message, add its three headers as `message` shows them: `in_reply_to` is its `msg_id`, `references` is its `headers.references` with its `msg_id` added at the end, and `in_reply_to_uid` is its `uid` as search showed it, `<number>@<folder id>`. A draft that names none is a new mail:
   ```sh
   yardr call infomaniak-mail draft mailbox=<uuid> from=<the mailbox's email> to=someone@example.com subject="Re: hello" body="On it." in_reply_to=<its msg_id> references="<its references> <its msg_id>" in_reply_to_uid=<its uid>
   ```
   A draft is written again, not edited: the old one stays in Drafts, and no call of this pack deletes it. The draft's sender and display name are the account's default; the pack sets no sending identity.

7. **When it does not work.**
   - *the service refuses the token*: a refusal such as a 401 or a 403, whatever Infomaniak answers. See step 5.
   - *404 on a message*: the folder is not the one the message is in. Use the `folder_id` of the search result, not the folder searched.
   - *the answer is over its bound*: yardr refuses an answer over the call's bound and does not cut it. A message is held to 1 MiB, and a search or the drafts to 256 KB. Ask for fewer results with a smaller `maxResults`.
   - *an argument is refused with its pattern*: the call was not sent. `yardr source show infomaniak-mail` lists each call's arguments and their patterns.
   - *a call that worked stops working, or answers in another shape*: Infomaniak changed its webmail's calls. Nothing announces it. README.md names what the pack was built from; compare it with the MCP server's current source.

8. **Ending.** Take the grants back, then remove the source and its program. The file with the token stays at `~/.yardr/sources/infomaniak-mail.env`, so delete it yourself, and revoke the token in the manager's list:
   ```sh
   yardr source revoke infomaniak-mail read --crew yardmaster
   ```
   ```sh
   yardr source rm infomaniak-mail
   ```
#!/bin/sh
# The infomaniak-mail pack's tests: what the gate runs beside yardr pack
# check. Needs no network and no secret, done in seconds. Needs jq, python3
# and yardr, and fails without any of them: a part that was skipped would
# leave the pack unpinned.
#
#   build  build-draft as a process, run as the yard runs it, over
#          testdata/build/*.in: the message it makes for each input, field
#          for field and in the order of Infomaniak's own server, the text
#          escaped as that server escapes it, and what it refuses in words.
#          Every message it makes is held to action save.
#   core   testdata/core.sh: the real yardr in a scratch yard. The
#          declaration as the yard reads it, the presets and the grants
#          SETUP.md teaches, the check, and each testdata/calls/*.case, one
#          `yardr call` against a stand-in for Infomaniak's mail API on a
#          loopback port (testdata/standin.py) that answers the fixtures of
#          testdata/answers/, which follow the mocked answers of the
#          server's own tests. A case's golden holds the request the
#          stand-in saw (method, path and query as they arrived, headers,
#          body), the exit status, what the call kept of the answer and what
#          it said on stderr; a call the declaration should turn away shows
#          as no request at all. All the requests together are held to the
#          calls that read, and the one that writes a draft with action
#          save. The token the yard was given is a made-up string: it must
#          reach the stand-in as a bearer token and be in no file of the
#          yard but the env file, and in no output.
#
# UPDATE_GOLDEN=1 ./test writes the goldens again from what the programs
# print; read the diff before keeping it.
set -u
cd "$(dirname "$0")" || exit 1
here=$(pwd)
fail=0

for tool in jq python3; do
  command -v "$tool" >/dev/null 2>&1 || { echo "test: $tool is not on PATH: the pack needs it"; exit 1; }
done
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
[ -x "$YARDR" ] || { echo "test: no yardr ($YARDR): the declaration and the calls are read through it"; exit 1; }

tmp=$(mktemp -d)
# The core part's scratch yard and stand-in are made when its turn comes,
# and the yard's server, which its first command starts, would outlive the
# directory it owns: stop it first. core is empty until then.
core=
standin_pid=
cleanup() {
  [ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
  if [ -n "${core:-}" ]; then
    env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
    rm -rf "${core:?}"
  fi
  rm -rf "${tmp:?}"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

# check <name> <actual file> <golden file>: ok when they are the same.
check() {
  if [ "${UPDATE_GOLDEN:-}" = 1 ]; then
    if ! cmp -s "$2" "$3"; then cp "$2" "$3"; echo "test: $1 golden written"; fi
    return
  fi
  if [ ! -e "$3" ]; then
    echo "test: $1 has no golden $3 (UPDATE_GOLDEN=1 ./test writes it)"
    fail=1
  elif cmp -s "$2" "$3"; then
    echo "test: $1 ok"
  else
    echo "test: $1 differs from $3:"
    diff "$3" "$2" | sed 's/^/    /' | head -30
    fail=1
  fi
}
# ok <name> <condition's exit status> [detail]
ok() {
  if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}

# ---- build-draft ---------------------------------------------------------------
mkdir "$tmp/work" "$tmp/home"
# build_run <stdin file> runs the program as the yard does, in a bare
# environment, from a directory of its own: stdout is in $tmp/b.out, stderr in
# $tmp/b.err, the status in $rc. PYTHONDONTWRITEBYTECODE is the test's and not
# the yard's: Apple's python3 3.9, the one a Mac has without another, caches the
# bytecode of the standard library it imports under $HOME/Library/Caches, and
# the check that the program writes no file below is of the program's own.
build_run() {
  (cd "$tmp/work" && env -i PATH="$PATH" HOME="$tmp/home" PYTHONDONTWRITEBYTECODE=1 "$here/build-draft") <"$1" >"$tmp/b.out" 2>"$tmp/b.err"
  rc=$?
}

# The fields of the message, in the order createDraft of Infomaniak's server
# writes them (src/mail-client.ts, read 10 November 2026).
want_keys='["uuid","subject","body","quote","mime_type","from","reply_to","to","cc","bcc","references","in_reply_to","in_reply_to_uid","forwarded_uid","attachments","identity_id","ack_request","st_uuid","uid","resource","priority","encrypted","encryption_password","event_poll_uuid","action","delay"]'

for c in testdata/build/*.in; do
  n=$(basename "$c" .in)
  build_run "$c"
  {
    echo "--- status"
    echo "$rc"
    echo "--- stdout"
    if [ -s "$tmp/b.out" ]; then jq . "$tmp/b.out" 2>/dev/null || cat "$tmp/b.out"; fi
    echo "--- stderr"
    cat "$tmp/b.err"
  } >"$tmp/b.golden"
  check "build $n" "$tmp/b.golden" "testdata/build/$n.golden"
  case $n in
    refused-*)
      # A refusal is one line, makes no message, and says nothing of the input's values.
      [ "$rc" = 1 ] && [ ! -s "$tmp/b.out" ] && [ "$(wc -l <"$tmp/b.err" | tr -d ' ')" = 1 ] && grep -q '^build-draft: ' "$tmp/b.err"
      ok "build $n is one refusal line with no message" $? "exit $rc, stdout $(wc -c <"$tmp/b.out") bytes, stderr: $(cat "$tmp/b.err")"
      ;;
    *)
      # Every message has the server's fields in the server's order and is a draft: save, no delay, no
      # identity, no attachment, one recipient list and no bcc, whatever the text said.
      [ "$rc" = 0 ] && jq -e --argjson keys "$want_keys" '
        (keys_unsorted == $keys) and .action == "save" and .delay == 0 and .mime_type == "text/html"
        and .identity_id == null and .attachments == [] and .bcc == null and .uuid == null and .uid == null
        and (.to | length) == 1 and (.cc == null or (.cc | length) == 1)' "$tmp/b.out" >/dev/null
      ok "build $n is a draft with the server's fields: action save, no delay, no identity" $? "exit $rc: $(head -c 300 "$tmp/b.err")"
      ;;
  esac
done

# The server's own expectation of its escaping, which its test spells out for
# this text (tests/mail-client.test.js, "createDraft escapes plain text body
# before wrapping it in HTML"): the text is escaped once and a newline is a <br>.
build_run testdata/build/escape.in
jq -e '(.body | contains("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;<br>Tom &amp; Jerry &quot;quote&quot;")) and (.body | contains("<script>") | not)' "$tmp/b.out" >/dev/null
ok "build escapes the text as the server does and puts none of it in a tag" $?

# Values that look like the message's own fields stay text: the action and the
# subject come out as they went in, and the one key "action" is save.
build_run testdata/build/injection.in
jq -e '.action == "save" and .subject == "x\",\"action\":\"send\",\"y\":\"" and .in_reply_to == "<a@b>\",\"action\":\"send" and (has("y") | not) and (has("z") | not)' "$tmp/b.out" >/dev/null
ok "build keeps a value that looks like JSON as text, and the action stays save" $?

# A reply carries its three headers, a new mail none, as the server's test of an
# explicit in_reply_to and references expects.
build_run testdata/build/reply.in
jq -e '.in_reply_to == "<invoice-june@example.com>" and .references == "<ledger-2026@example.com> <invoice-june@example.com>" and .in_reply_to_uid == "20@eJzz9HPyjwAABGYBgQ==" and .cc == [{"name":"","email":"accounts@example.com"}]' "$tmp/b.out" >/dev/null
ok "build gives a reply its in_reply_to, references and in_reply_to_uid, and cc as one recipient" $?
build_run testdata/build/new.in
jq -e '.in_reply_to == null and .in_reply_to_uid == null and .references == "" and .cc == null and .from == {"id":null,"name":"yardr","email":"yardr@example.com"} and .reply_to == {"name":"yardr","email":"yardr@example.com"} and .to == [{"name":"","email":"alice@example.com"}]' "$tmp/b.out" >/dev/null
ok "build gives a new mail no reply headers, the server's null and empty values, and the sender's local part as its name" $?

# The program makes no request and writes nothing: no file in its directory or
# its home, and it holds no address or credential of the service.
[ -z "$(find "$tmp/work" "$tmp/home" -type f 2>/dev/null)" ]
ok "build writes no file" $?
! grep -n -i -e 'urllib' -e 'http\.client' -e 'socket' -e 'subprocess' -e 'INFOMANIAK_MAIL_TOKEN' -e 'environ' build-draft | grep -v '^[0-9]*:#' | grep -q .
ok "build-draft imports nothing that reaches a network or the environment, and names no token" $?

# ---- core ----------------------------------------------------------------------
. ./testdata/core.sh

if [ "$fail" = 0 ]; then echo "test: infomaniak-mail: all ok"; else echo "test: infomaniak-mail: FAILED"; fi
exit $fail
{
  "result": "success",
  "data": {
    "uuid": "5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e",
    "uid": "7"
  }
}
{
  "result": "success",
  "data": {
    "threads": [
      {
        "uid": "t-1",
        "subject": "Draft 1",
        "from": [],
        "to": [
          {
            "name": "Alice Martin",
            "email": "alice@example.com"
          }
        ],
        "date": "2026-06-17T10:00:00+0200",
        "messages_count": 1,
        "unseen_messages": 0,
        "messages": [
          {
            "uid": "1@eJxzKUpMKykGAAfpAmU=",
            "preview": "Hello Alice, about the invoice",
            "folder_id": "eJxzKUpMKykGAAfpAmU=",
            "folder": "Drafts"
          }
        ]
      }
    ]
  }
}
{
  "result": "success",
  "data": [
    {
      "id": "eJzz9HPyjwAABGYBgQ==",
      "name": "INBOX",
      "separator": "/",
      "role": "INBOX",
      "unread_count": 2,
      "total_count": 14,
      "children": []
    },
    {
      "id": "eJxzKUpMKykGAAfpAmU=",
      "name": "Drafts",
      "separator": "/",
      "role": "DRAFT",
      "unread_count": 0,
      "total_count": 1,
      "children": []
    },
    {
      "id": "eJwLTs0rAQADzwGb",
      "name": "Sent",
      "separator": "/",
      "role": "SENT",
      "unread_count": 0,
      "total_count": 9,
      "children": []
    },
    {
      "id": "eJxzLErOyCxLLQYADcsDNg==",
      "name": "Archives",
      "separator": "/",
      "role": "ARCHIVE",
      "unread_count": 0,
      "total_count": 120,
      "children": []
    },
    {
      "id": "eJwLKUoszgAABeICAw==",
      "name": "Trash",
      "separator": "/",
      "role": "TRASH",
      "unread_count": 0,
      "total_count": 3,
      "children": []
    },
    {
      "id": "eJwLKMrPSi0pBgALQALo",
      "name": "Projets",
      "separator": "/",
      "role": null,
      "unread_count": 0,
      "total_count": 0,
      "children": [
        {
          "id": "eJwLKMrPSi0p1j/cWXJ4pYKRgZEZAEkpBy0=",
          "name": "Été 2026",
          "separator": "/",
          "role": null,
          "unread_count": 0,
          "total_count": 0,
          "children": [
            {
              "id": "eJwLKMrPSi0p1j/cWXJ4pYKRgZGZflh+ZWJ6qsLhBQpRh/cUZSZnAAAPNQ7p",
              "name": "Voyage à Zürich",
              "separator": "/",
              "role": null,
              "unread_count": 0,
              "total_count": 4,
              "children": []
            }
          ]
        }
      ]
    }
  ]
}
{
  "result": "success",
  "data": [
    {
      "uuid": "8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b",
      "hosting_id": 123456,
      "mailbox": "yardr",
      "email": "yardr@example.com",
      "is_primary": true,
      "aliases": [
        "hello@example.com"
      ]
    }
  ]
}
{
  "result": "success",
  "data": {
    "uid": "20",
    "msg_id": "<invoice-june@example.com>",
    "subject": "Invoice June",
    "from": [
      {
        "name": "Alice Martin",
        "email": "alice@example.com"
      }
    ],
    "to": [
      {
        "name": "Yardr",
        "email": "yardr@example.com"
      }
    ],
    "cc": [
      {
        "name": "Accounts",
        "email": "accounts@example.com"
      }
    ],
    "bcc": [],
    "date": "2026-06-15T08:00:00+0200",
    "body": {
      "value": "Hello,\nYour invoice for June is attached.\n",
      "type": "text/plain"
    },
    "html": "<p>Hello,</p><p>Your invoice for June is attached.</p>",
    "preview": "Hello, your invoice for June is attached.",
    "has_attachments": true,
    "seen": false,
    "flagged": false,
    "folder": "INBOX",
    "headers": {
      "subject": "Invoice June",
      "references": "<ledger-2026@example.com>"
    },
    "attachments": [
      {
        "part_id": 2,
        "name": "invoice-june.pdf",
        "mime_type": "application/pdf",
        "size": 12450,
        "resource": "/api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message/20/attachment/2"
      }
    ]
  }
}
{
  "result": "error"
}
{
  "result": "error"
}
{
  "result": "success",
  "data": {
    "threads": [
      {
        "uid": "t-20",
        "subject": "Invoice June",
        "from": [
          {
            "name": "Alice Martin",
            "email": "alice@example.com"
          }
        ],
        "to": [
          {
            "name": "Yardr",
            "email": "yardr@example.com"
          }
        ],
        "date": "2026-06-15T08:00:00+0200",
        "messages_count": 1,
        "unseen_messages": 1,
        "messages": [
          {
            "uid": "20@eJzz9HPyjwAABGYBgQ==",
            "preview": "Hello, your invoice for June is attached.",
            "folder_id": "eJzz9HPyjwAABGYBgQ==",
            "folder": "INBOX"
          }
        ]
      },
      {
        "uid": "t-10",
        "subject": "Re: Invoice June",
        "from": [
          {
            "name": "Yardr",
            "email": "yardr@example.com"
          }
        ],
        "to": [
          {
            "name": "Alice Martin",
            "email": "alice@example.com"
          }
        ],
        "date": "2026-06-16T09:30:00+0200",
        "messages_count": 1,
        "unseen_messages": 0,
        "messages": [
          {
            "uid": "10@eJwLTs0rAQADzwGb",
            "preview": "Thanks, paid today.",
            "folder_id": "eJwLTs0rAQADzwGb",
            "folder": "Sent"
          }
        ]
      }
    ]
  }
}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\"></div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": null,
  "bcc": null,
  "references": "",
  "in_reply_to": null,
  "in_reply_to_uid": null,
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "", "body": ""}}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "Escaped body",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;<br>Tom &amp; Jerry &quot;quote&quot;</div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": null,
  "bcc": null,
  "references": "",
  "in_reply_to": null,
  "in_reply_to_uid": null,
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Escaped body", "body": "<script>alert('x')</script>\nTom & Jerry \"quote\""}}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "x\",\"action\":\"send\",\"y\":\"",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">&quot;},&quot;action&quot;:&quot;send&quot;,&quot;delay&quot;:0,&quot;z&quot;:{&quot;a&quot;:&quot;<br>&lt;/div&gt;&lt;script&gt;send()&lt;/script&gt;</div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": null,
  "bcc": null,
  "references": "",
  "in_reply_to": "<a@b>\",\"action\":\"send",
  "in_reply_to_uid": null,
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "x\",\"action\":\"send\",\"y\":\"", "body": "\"},\"action\":\"send\",\"delay\":0,\"z\":{\"a\":\"\n</div><script>send()</script>", "in_reply_to": "<a@b>\",\"action\":\"send"}}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "Invoice June",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Hello Alice,<br>Thanks for the invoice.<br></div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": null,
  "bcc": null,
  "references": "",
  "in_reply_to": null,
  "in_reply_to_uid": null,
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Invoice June", "body": "Hello Alice,\nThanks for the invoice.\n"}}
--- status
1
--- stdout
--- stderr
build-draft: action is not a parameter of a draft
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Hi", "body": "Hi", "action": "send"}}
--- status
1
--- stdout
--- stderr
build-draft: to holds a line break: a header is one line
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com\r", "subject": "Hi", "body": "Hi"}}
--- status
1
--- stdout
--- stderr
build-draft: subject holds a line break: a header is one line
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Hi\nBcc: eve@example.com", "body": "Hi"}}
--- status
1
--- stdout
--- stderr
build-draft: a draft needs to, subject, body
{"params": {"mailbox": "mb", "from": "yardr@example.com"}}
--- status
1
--- stdout
--- stderr
build-draft: stdin is no JSON
not json
--- status
1
--- stdout
--- stderr
build-draft: stdin is no {"params": {...}}
{}
--- status
1
--- stdout
--- stderr
build-draft: subject is no text
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": 5, "body": "Hi"}}
--- status
1
--- stdout
--- stderr
build-draft: stdin is no {"params": {...}}
{"params": []}
--- status
1
--- stdout
--- stderr
build-draft: bcc is not a parameter of a draft
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Hi", "body": "Hi", "bcc": "eve@example.com"}}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "Re: Invoice June",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Paid today.</div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": [
    {
      "name": "",
      "email": "accounts@example.com"
    }
  ],
  "bcc": null,
  "references": "<ledger-2026@example.com> <invoice-june@example.com>",
  "in_reply_to": "<invoice-june@example.com>",
  "in_reply_to_uid": "20@eJzz9HPyjwAABGYBgQ==",
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b", "from": "yardr@example.com", "to": "alice@example.com", "cc": "accounts@example.com", "subject": "Re: Invoice June", "body": "Paid today.", "in_reply_to": "<invoice-june@example.com>", "references": "<ledger-2026@example.com> <invoice-june@example.com>", "in_reply_to_uid": "20@eJzz9HPyjwAABGYBgQ=="}}
--- status
0
--- stdout
{
  "uuid": null,
  "subject": "Café ☕ — 日本語 😀",
  "body": "<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Voyage à Zürich: €120<br>Été 2026 — 😀 日本語<br></div></body></html>",
  "quote": null,
  "mime_type": "text/html",
  "from": {
    "id": null,
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "reply_to": {
    "name": "yardr",
    "email": "yardr@example.com"
  },
  "to": [
    {
      "name": "",
      "email": "alice@example.com"
    }
  ],
  "cc": null,
  "bcc": null,
  "references": "",
  "in_reply_to": null,
  "in_reply_to_uid": null,
  "forwarded_uid": null,
  "attachments": [],
  "identity_id": null,
  "ack_request": false,
  "st_uuid": null,
  "uid": null,
  "resource": null,
  "priority": "normal",
  "encrypted": false,
  "encryption_password": "",
  "event_poll_uuid": null,
  "action": "save",
  "delay": 0
}
--- stderr
{"params": {"mailbox": "mb", "from": "yardr@example.com", "to": "alice@example.com", "subject": "Café ☕ — 日本語 😀", "body": "Voyage à Zürich: €120\nÉté 2026 — 😀 日本語\n"}}
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
action=send
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: \"action\" is no parameter of the call (it takes: mailbox, from, to, cc, subject, body, in_reply_to, references, in_reply_to_uid)"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
in_reply_to_uid=20
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: parameter in_reply_to_uid does not match its pattern ^[0-9]+@[A-Za-z0-9_=+/-]+$"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
bcc=eve@example.com
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: \"bcc\" is no parameter of the call (it takes: mailbox, from, to, cc, subject, body, in_reply_to, references, in_reply_to_uid)"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
uuid=5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: \"uuid\" is no parameter of the call (it takes: mailbox, from, to, cc, subject, body, in_reply_to, references, in_reply_to_uid)"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Escaped body
body=<script>alert('x')</script> Tom & Jerry "quote"
=== answer
@draft
--- requests
POST /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
  auth: ok
  accept: application/json
  content-type: application/json
  user-agent: yardr
  body: {"uuid":null,"subject":"Escaped body","body":"<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; Tom &amp; Jerry &quot;quote&quot;</div></body></html>","quote":null,"mime_type":"text/html","from":{"id":null,"name":"yardr","email":"yardr@example.com"},"reply_to":{"name":"yardr","email":"yardr@example.com"},"to":[{"name":"","email":"alice@example.com"}],"cc":null,"bcc":null,"references":"","in_reply_to":null,"in_reply_to_uid":null,"forwarded_uid":null,"attachments":[],"identity_id":null,"ack_request":false,"st_uuid":null,"uid":null,"resource":null,"priority":"normal","encrypted":false,"encryption_password":"","event_poll_uuid":null,"action":"save","delay":0}
--- status
0
--- stdout
{
  "data": {
    "uid": "7",
    "uuid": "5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e"
  },
  "result": "success"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: parameter body is missing"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
to=alice@example.com
subject=Hi
body=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: parameter from is missing"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
raw=VG86IGFsaWNl
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: \"raw\" is no parameter of the call (it takes: mailbox, from, to, cc, subject, body, in_reply_to, references, in_reply_to_uid)"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
cc=accounts@example.com
subject=Re: Invoice June
body=Paid today.
in_reply_to=<invoice-june@example.com>
references=<ledger-2026@example.com> <invoice-june@example.com>
in_reply_to_uid=20@eJzz9HPyjwAABGYBgQ==
=== answer
@draft
--- requests
POST /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
  auth: ok
  accept: application/json
  content-type: application/json
  user-agent: yardr
  body: {"uuid":null,"subject":"Re: Invoice June","body":"<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Paid today.</div></body></html>","quote":null,"mime_type":"text/html","from":{"id":null,"name":"yardr","email":"yardr@example.com"},"reply_to":{"name":"yardr","email":"yardr@example.com"},"to":[{"name":"","email":"alice@example.com"}],"cc":[{"name":"","email":"accounts@example.com"}],"bcc":null,"references":"<ledger-2026@example.com> <invoice-june@example.com>","in_reply_to":"<invoice-june@example.com>","in_reply_to_uid":"20@eJzz9HPyjwAABGYBgQ==","forwarded_uid":null,"attachments":[],"identity_id":null,"ack_request":false,"st_uuid":null,"uid":null,"resource":null,"priority":"normal","encrypted":false,"encryption_password":"","event_poll_uuid":null,"action":"save","delay":0}
--- status
0
--- stdout
{
  "data": {
    "uid": "7",
    "uuid": "5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e"
  },
  "result": "success"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hello
=== answer
@refused
=== status
401
--- requests
POST /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
  auth: ok
  accept: application/json
  content-type: application/json
  user-agent: yardr
  body: {"uuid":null,"subject":"Hi","body":"<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Hello</div></body></html>","quote":null,"mime_type":"text/html","from":{"id":null,"name":"yardr","email":"yardr@example.com"},"reply_to":{"name":"yardr","email":"yardr@example.com"},"to":[{"name":"","email":"alice@example.com"}],"cc":null,"bcc":null,"references":"","in_reply_to":null,"in_reply_to_uid":null,"forwarded_uid":null,"attachments":[],"identity_id":null,"ack_request":false,"st_uuid":null,"uid":null,"resource":null,"priority":"normal","encrypted":false,"encryption_password":"","event_poll_uuid":null,"action":"save","delay":0}
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: the service answered 401"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=Alice <alice@example.com>
subject=Hi
body=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: parameter to does not match its pattern ^[^@[:space:]]+@[^@[:space:]]+$"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com,eve@example.com
subject=Hi
body=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call draft: parameter to does not match its pattern ^[^@[:space:]]+@[^@[:space:]]+$"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Café ☕ — 日本語 😀
body=Voyage à Zürich: €120 — Été 2026 😀
=== answer
@draft
--- requests
POST /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
  auth: ok
  accept: application/json
  content-type: application/json
  user-agent: yardr
  body: {"uuid":null,"subject":"Caf\u00e9 \u2615 \u2014 \u65e5\u672c\u8a9e \ud83d\ude00","body":"<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Voyage \u00e0 Z\u00fcrich: \u20ac120 \u2014 \u00c9t\u00e9 2026 \ud83d\ude00</div></body></html>","quote":null,"mime_type":"text/html","from":{"id":null,"name":"yardr","email":"yardr@example.com"},"reply_to":{"name":"yardr","email":"yardr@example.com"},"to":[{"name":"","email":"alice@example.com"}],"cc":null,"bcc":null,"references":"","in_reply_to":null,"in_reply_to_uid":null,"forwarded_uid":null,"attachments":[],"identity_id":null,"ack_request":false,"st_uuid":null,"uid":null,"resource":null,"priority":"normal","encrypted":false,"encryption_password":"","event_poll_uuid":null,"action":"save","delay":0}
--- status
0
--- stdout
{
  "data": {
    "uid": "7",
    "uuid": "5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e"
  },
  "result": "success"
}
--- stderr
=== args
draft
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
from=yardr@example.com
to=alice@example.com
subject=Invoice June
body=Hello Alice, thanks for the invoice.
=== answer
@draft
--- requests
POST /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
  auth: ok
  accept: application/json
  content-type: application/json
  user-agent: yardr
  body: {"uuid":null,"subject":"Invoice June","body":"<html><body><div style=\"font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px;\">Hello Alice, thanks for the invoice.</div></body></html>","quote":null,"mime_type":"text/html","from":{"id":null,"name":"yardr","email":"yardr@example.com"},"reply_to":{"name":"yardr","email":"yardr@example.com"},"to":[{"name":"","email":"alice@example.com"}],"cc":null,"bcc":null,"references":"","in_reply_to":null,"in_reply_to_uid":null,"forwarded_uid":null,"attachments":[],"identity_id":null,"ack_request":false,"st_uuid":null,"uid":null,"resource":null,"priority":"normal","encrypted":false,"encryption_password":"","event_poll_uuid":null,"action":"save","delay":0}
--- status
0
--- stdout
{
  "data": {
    "uid": "7",
    "uuid": "5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e"
  },
  "result": "success"
}
--- stderr
=== args
drafts
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJxzKUpMKykGAAfpAmU=
maxResults=0
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call drafts: parameter maxResults does not match its pattern ^([1-9]|[1-4][0-9]|50)$"
}
--- stderr
=== args
drafts
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJxzKUpMKykGAAfpAmU=
maxResults=50
offset=50
=== answer
@drafts
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJxzKUpMKykGAAfpAmU=/message?limit=50&offset=50&severywhere=0&thread=on
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-17T10:00:00+0200",
        "from": [],
        "messages": [
          {
            "folder": "Drafts",
            "folder_id": "eJxzKUpMKykGAAfpAmU=",
            "preview": "Hello Alice, about the invoice",
            "uid": "1@eJxzKUpMKykGAAfpAmU="
          }
        ],
        "messages_count": 1,
        "subject": "Draft 1",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-1",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
=== args
drafts
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJxzKUpMKykGAAfpAmU=
maxResults=10
=== answer
@drafts
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJxzKUpMKykGAAfpAmU=/message?limit=10&offset=0&severywhere=0&thread=on
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-17T10:00:00+0200",
        "from": [],
        "messages": [
          {
            "folder": "Drafts",
            "folder_id": "eJxzKUpMKykGAAfpAmU=",
            "preview": "Hello Alice, about the invoice",
            "uid": "1@eJxzKUpMKykGAAfpAmU="
          }
        ],
        "messages_count": 1,
        "subject": "Draft 1",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-1",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
=== args
folders
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/draft
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call folders: parameter mailbox does not match its pattern ^[A-Za-z0-9_-]{1,64}$"
}
--- stderr
=== args
folders
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call folders: parameter mailbox is missing"
}
--- stderr
=== args
folders
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
=== answer
@folders
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder?with=ik-static
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": [
    {
      "children": [],
      "id": "eJzz9HPyjwAABGYBgQ==",
      "name": "INBOX",
      "role": "INBOX",
      "separator": "/",
      "total_count": 14,
      "unread_count": 2
    },
    {
      "children": [],
      "id": "eJxzKUpMKykGAAfpAmU=",
      "name": "Drafts",
      "role": "DRAFT",
      "separator": "/",
      "total_count": 1,
      "unread_count": 0
    },
    {
      "children": [],
      "id": "eJwLTs0rAQADzwGb",
      "name": "Sent",
      "role": "SENT",
      "separator": "/",
      "total_count": 9,
      "unread_count": 0
    },
    {
      "children": [],
      "id": "eJxzLErOyCxLLQYADcsDNg==",
      "name": "Archives",
      "role": "ARCHIVE",
      "separator": "/",
      "total_count": 120,
      "unread_count": 0
    },
    {
      "children": [],
      "id": "eJwLKUoszgAABeICAw==",
      "name": "Trash",
      "role": "TRASH",
      "separator": "/",
      "total_count": 3,
      "unread_count": 0
    },
    {
      "children": [
        {
          "children": [
            {
              "children": [],
              "id": "eJwLKMrPSi0p1j/cWXJ4pYKRgZGZflh+ZWJ6qsLhBQpRh/cUZSZnAAAPNQ7p",
              "name": "Voyage à Zürich",
              "role": null,
              "separator": "/",
              "total_count": 4,
              "unread_count": 0
            }
          ],
          "id": "eJwLKMrPSi0p1j/cWXJ4pYKRgZEZAEkpBy0=",
          "name": "Été 2026",
          "role": null,
          "separator": "/",
          "total_count": 0,
          "unread_count": 0
        }
      ],
      "id": "eJwLKMrPSi0pBgALQALo",
      "name": "Projets",
      "role": null,
      "separator": "/",
      "total_count": 0,
      "unread_count": 0
    }
  ],
  "result": "success"
}
--- stderr
=== args
mailboxes
=== answer
@refused
=== status
401
--- requests
GET /api/mailbox?with=aliases%2Cpermissions%2CaccountId%2Ccount_users
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call mailboxes: the service answered 401"
}
--- stderr
=== args
mailboxes
=== answer
@mailboxes
--- requests
GET /api/mailbox?with=aliases%2Cpermissions%2CaccountId%2Ccount_users
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": [
    {
      "aliases": [
        "hello@example.com"
      ],
      "email": "yardr@example.com",
      "hosting_id": 123456,
      "is_primary": true,
      "mailbox": "yardr",
      "uuid": "8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b"
    }
  ],
  "result": "success"
}
--- stderr
=== args
message
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
id=20@eJzz9HPyjwAABGYBgQ==
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call message: parameter id is 23 characters, and at most 12"
}
--- stderr
=== args
message
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
id=99999
=== answer
@not-found
=== status
404
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message/99999?prefered_format=html&with=auto_uncrypt%2Cthread_context
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call message: the service answered 404"
}
--- stderr
=== args
message
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJwLTs0rAQADzwGb
id=10
=== answer
@message
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJwLTs0rAQADzwGb/message/10?prefered_format=html&with=auto_uncrypt%2Cthread_context
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "attachments": [
      {
        "mime_type": "application/pdf",
        "name": "invoice-june.pdf",
        "part_id": 2,
        "resource": "/api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message/20/attachment/2",
        "size": 12450
      }
    ],
    "bcc": [],
    "body": {
      "type": "text/plain",
      "value": "Hello,\nYour invoice for June is attached.\n"
    },
    "cc": [
      {
        "email": "accounts@example.com",
        "name": "Accounts"
      }
    ],
    "date": "2026-06-15T08:00:00+0200",
    "flagged": false,
    "folder": "INBOX",
    "from": [
      {
        "email": "alice@example.com",
        "name": "Alice Martin"
      }
    ],
    "has_attachments": true,
    "headers": {
      "references": "<ledger-2026@example.com>",
      "subject": "Invoice June"
    },
    "html": "<p>Hello,</p><p>Your invoice for June is attached.</p>",
    "msg_id": "<invoice-june@example.com>",
    "preview": "Hello, your invoice for June is attached.",
    "seen": false,
    "subject": "Invoice June",
    "to": [
      {
        "email": "yardr@example.com",
        "name": "Yardr"
      }
    ],
    "uid": "20"
  },
  "result": "success"
}
--- stderr
=== args
message
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
id=20
=== answer
@message
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message/20?prefered_format=html&with=auto_uncrypt%2Cthread_context
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "attachments": [
      {
        "mime_type": "application/pdf",
        "name": "invoice-june.pdf",
        "part_id": 2,
        "resource": "/api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message/20/attachment/2",
        "size": 12450
      }
    ],
    "bcc": [],
    "body": {
      "type": "text/plain",
      "value": "Hello,\nYour invoice for June is attached.\n"
    },
    "cc": [
      {
        "email": "accounts@example.com",
        "name": "Accounts"
      }
    ],
    "date": "2026-06-15T08:00:00+0200",
    "flagged": false,
    "folder": "INBOX",
    "from": [
      {
        "email": "alice@example.com",
        "name": "Alice Martin"
      }
    ],
    "has_attachments": true,
    "headers": {
      "references": "<ledger-2026@example.com>",
      "subject": "Invoice June"
    },
    "html": "<p>Hello,</p><p>Your invoice for June is attached.</p>",
    "msg_id": "<invoice-june@example.com>",
    "preview": "Hello, your invoice for June is attached.",
    "seen": false,
    "subject": "Invoice June",
    "to": [
      {
        "email": "yardr@example.com",
        "name": "Yardr"
      }
    ],
    "uid": "20"
  },
  "result": "success"
}
--- stderr
=== args
delete
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
id=20
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail has no call \"delete\" (it has: mailboxes, folders, search, message, drafts, draft)"
}
--- stderr
=== args
draft_edit
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
id=5b0f4c3e-7a21-4d96-b8e3-0c1f2a3b4d5e
from=yardr@example.com
to=alice@example.com
subject=Hi
body=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail has no call \"draft_edit\" (it has: mailboxes, folders, search, message, drafts, draft)"
}
--- stderr
=== args
send
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
to=alice@example.com
subject=Hi
body=Hi
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail has no call \"send\" (it has: mailboxes, folders, search, message, drafts, draft)"
}
--- stderr
=== args
thread
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
id=20
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail has no call \"thread\" (it has: mailboxes, folders, search, message, drafts, draft)"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
everywhere=2
maxResults=10
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter everywhere is one of 0, 1"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=../eJzz9HPyjwAABGYBgQ==
maxResults=10
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter folder does not match its pattern ^[A-Za-z0-9_=+/-]{1,512}$"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
q=invoice
maxResults=51
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter maxResults does not match its pattern ^([1-9]|[1-4][0-9]|50)$"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
offset=-1
maxResults=10
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter offset does not match its pattern ^[0-9]{1,6}$"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
since=2026-06-01
maxResults=10
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter since does not match its pattern ^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
from=alice@example.com
to=yardr@example.com
subject=Invoice
since=2026-06-01 00:00:00
before=2026-07-01 00:00:00
everywhere=0
maxResults=50
offset=50
=== answer
@search
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message?limit=50&offset=50&severywhere=0&sfrom=alice%40example.com&sfromdate=2026-06-01+00%3A00%3A00&ssubject=Invoice&sto=yardr%40example.com&stodate=2026-07-01+00%3A00%3A00&thread=off
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-15T08:00:00+0200",
        "from": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "messages": [
          {
            "folder": "INBOX",
            "folder_id": "eJzz9HPyjwAABGYBgQ==",
            "preview": "Hello, your invoice for June is attached.",
            "uid": "20@eJzz9HPyjwAABGYBgQ=="
          }
        ],
        "messages_count": 1,
        "subject": "Invoice June",
        "to": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "uid": "t-20",
        "unseen_messages": 1
      },
      {
        "date": "2026-06-16T09:30:00+0200",
        "from": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "messages": [
          {
            "folder": "Sent",
            "folder_id": "eJwLTs0rAQADzwGb",
            "preview": "Thanks, paid today.",
            "uid": "10@eJwLTs0rAQADzwGb"
          }
        ],
        "messages_count": 1,
        "subject": "Re: Invoice June",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-10",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJwLKMrPSi0p1j/cWXJ4pYKRgZGZflh+ZWJ6qsLhBQpRh/cUZSZnAAAPNQ7p
q=billet
maxResults=5
=== answer
@search
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJwLKMrPSi0p1j%2FcWXJ4pYKRgZGZflh+ZWJ6qsLhBQpRh%2FcUZSZnAAAPNQ7p/message?limit=5&offset=0&scontains=billet&severywhere=1&thread=off
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-15T08:00:00+0200",
        "from": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "messages": [
          {
            "folder": "INBOX",
            "folder_id": "eJzz9HPyjwAABGYBgQ==",
            "preview": "Hello, your invoice for June is attached.",
            "uid": "20@eJzz9HPyjwAABGYBgQ=="
          }
        ],
        "messages_count": 1,
        "subject": "Invoice June",
        "to": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "uid": "t-20",
        "unseen_messages": 1
      },
      {
        "date": "2026-06-16T09:30:00+0200",
        "from": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "messages": [
          {
            "folder": "Sent",
            "folder_id": "eJwLTs0rAQADzwGb",
            "preview": "Thanks, paid today.",
            "uid": "10@eJwLTs0rAQADzwGb"
          }
        ],
        "messages_count": 1,
        "subject": "Re: Invoice June",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-10",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
maxResults=10
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter folder is missing"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
q=invoice
--- requests
--- status
1
--- stdout
{
  "error": "source infomaniak-mail, call search: parameter maxResults is missing"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
everywhere=0
maxResults=20
=== answer
@search
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message?limit=20&offset=0&severywhere=0&thread=off
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-15T08:00:00+0200",
        "from": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "messages": [
          {
            "folder": "INBOX",
            "folder_id": "eJzz9HPyjwAABGYBgQ==",
            "preview": "Hello, your invoice for June is attached.",
            "uid": "20@eJzz9HPyjwAABGYBgQ=="
          }
        ],
        "messages_count": 1,
        "subject": "Invoice June",
        "to": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "uid": "t-20",
        "unseen_messages": 1
      },
      {
        "date": "2026-06-16T09:30:00+0200",
        "from": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "messages": [
          {
            "folder": "Sent",
            "folder_id": "eJwLTs0rAQADzwGb",
            "preview": "Thanks, paid today.",
            "uid": "10@eJwLTs0rAQADzwGb"
          }
        ],
        "messages_count": 1,
        "subject": "Re: Invoice June",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-10",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
=== args
search
mailbox=8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b
folder=eJzz9HPyjwAABGYBgQ==
q=invoice & more
maxResults=10
=== answer
@search
--- requests
GET /api/mail/8f1a2b3c-4d5e-4f60-8a7b-9c0d1e2f3a4b/folder/eJzz9HPyjwAABGYBgQ==/message?limit=10&offset=0&scontains=invoice+%26+more&severywhere=1&thread=off
  auth: ok
  accept: application/json
  user-agent: yardr
--- status
0
--- stdout
{
  "data": {
    "threads": [
      {
        "date": "2026-06-15T08:00:00+0200",
        "from": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "messages": [
          {
            "folder": "INBOX",
            "folder_id": "eJzz9HPyjwAABGYBgQ==",
            "preview": "Hello, your invoice for June is attached.",
            "uid": "20@eJzz9HPyjwAABGYBgQ=="
          }
        ],
        "messages_count": 1,
        "subject": "Invoice June",
        "to": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "uid": "t-20",
        "unseen_messages": 1
      },
      {
        "date": "2026-06-16T09:30:00+0200",
        "from": [
          {
            "email": "yardr@example.com",
            "name": "Yardr"
          }
        ],
        "messages": [
          {
            "folder": "Sent",
            "folder_id": "eJwLTs0rAQADzwGb",
            "preview": "Thanks, paid today.",
            "uid": "10@eJwLTs0rAQADzwGb"
          }
        ],
        "messages_count": 1,
        "subject": "Re: Invoice June",
        "to": [
          {
            "email": "alice@example.com",
            "name": "Alice Martin"
          }
        ],
        "uid": "t-10",
        "unseen_messages": 0
      }
    ]
  },
  "result": "success"
}
--- stderr
GET /api/mailbox?with=aliases%2Cpermissions%2CaccountId%2Ccount_users
  auth: ok
  accept: application/json
  user-agent: yardr
# The core part of the infomaniak-mail test, sourced by ./test: the declaration
# as the yard reads it, the presets and the grants SETUP.md teaches, the check
# call, and each testdata/calls/*.case made through the real yardr against a
# stand-in on a loopback port (testdata/standin.py). Everything runs in a
# scratch yard under a short path (a unix socket's path is held to 100 bytes)
# with its own home and an environment of nothing but PATH: none of this
# session's YARDR_* variables lead back to a real yard. No daemon is started,
# so no agent is, and the only listener is the stand-in's, on 127.0.0.1 and a
# port the system chooses.
#
# ./test has set $YARDR, $here, $tmp, check, ok and cleanup (which stops the
# stand-in by its pid, stops the scratch yard's server, and removes the
# scratch yard, whatever core is by then).
core=$(mktemp -d /tmp/imkt.XXXXXX) || { core=; echo "test: core FAILED: no temp directory under /tmp for the scratch yard"; exit 1; }
mkdir -p "$core/h"
yrun() { env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester "$@"; }

# The token typed into the scratch yard: a made-up string, new for each run and
# no secret, which the stand-in takes for the one right token. No file of the
# pack holds one.
token=TESTTOKEN-$$-$(date +%s)

if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
  echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
  fail=1
elif ! yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
  echo "test: core FAILED: the pack did not apply from its directory: $(tail -c 300 "$core/apply.out")"
  fail=1
else
  # -- the declaration as the yard reads it, with the shipped base
  yrun source show infomaniak-mail --json | jq -S .declaration >"$core/declaration.json"
  check "declaration as the yard reads it (base, credential, calls)" "$core/declaration.json" testdata/declaration.golden
  # The kinds are what keeps writing apart, and the methods and paths what keeps
  # sending, moving and deleting out: five reads that all GET, and one update
  # that POSTs to the draft's address, whose body draft builds with build-draft.
  jq -e '.base == "https://mail.infomaniak.com/api" and .check == "mailboxes" and (has("idempotency") | not)
         and ([.calls[] | select(.kind == "read") | .name] == ["mailboxes", "folders", "search", "message", "drafts"])
         and ([.calls[] | select(.kind == "read") | .request.method] | all(. == "GET"))
         and ([.calls[] | select(.kind == "update") | .name] == ["draft"])
         and ([.calls[] | select(.name == "draft") | .request | .method + " " + .path] == ["POST /mail/{mailbox}/draft"])
         and ([.calls[] | select(.name == "draft") | .build] == [["build-draft"]])
         and ([.calls[] | select(.name != "draft") | has("build")] | any | not)
         and ([.calls[] | .request.path] | all(test("/(seen|unseen|star|unstar|move|delete|archive|rename|attachment|send)") | not))
         and .credential.env == "INFOMANIAK_MAIL_TOKEN" and .credential.header == "Authorization: Bearer {credential}"
         and (.credential | has("mint") | not) and (.credential | has("update_env") | not)' "$core/declaration.json" >/dev/null
  ok "the shipped base is Infomaniak's, five calls read (all GET) and draft is the one update (a POST to the draft's address, built by build-draft), no call's path moves, marks or deletes, and the token is one bearer variable" $?
  yrun pack show infomaniak-mail >"$core/packshow.out" 2>&1
  grep -q '1\. read: ' "$core/packshow.out" && grep -q '2\. drafts: ' "$core/packshow.out" &&
    awk '/1\. read: /{ r = 1; next } /2\. drafts: /{ r = 2; next } r == 1 && /infomaniak-mail:/ { read = $0 } r == 2 && /infomaniak-mail:/ { drafts = $0 }
         END { exit !(read ~ /infomaniak-mail: read$/ && drafts ~ /infomaniak-mail: read, draft$/) }' "$core/packshow.out"
  ok "pack show prints the two presets, read granting read and drafts granting read and draft" $?

  # -- SETUP.md says what the token is and where it is made
  grep -q 'INFOMANIAK_MAIL_TOKEN' SETUP.md && grep -q 'workspace:mail' SETUP.md && grep -q 'manager.infomaniak.com/v3/ng/accounts/token/list' SETUP.md
  ok "SETUP.md names the variable, the scope workspace:mail and the manager's token list" $?

  # -- the grants SETUP.md teaches, run as written. They are read out of
  # SETUP.md and not copied here: a copy would pass whatever SETUP teaches,
  # and --preset is refused for a pack applied from a directory. The card of
  # SETUP.md grants the group reviewers, which a fresh yard has none of.
  grep -o 'source grant [^`"]*' SETUP.md >"$core/grants"
  if [ ! -s "$core/grants" ]; then
    echo "test: grant FAILED: SETUP.md teaches no source grant command"
    fail=1
  elif ! yrun group add reviewers --runner manual >"$core/out" 2>&1; then
    echo "test: grant FAILED: the group of the card could not be made: $(tail -c 300 "$core/out")"
    fail=1
  else
    while IFS= read -r cmd; do
      # A grant is plain words, so $cmd is split on purpose. Its stdin is
      # /dev/null so that it cannot read the rest of the list.
      # shellcheck disable=SC2086
      if yrun $cmd </dev/null >"$core/out" 2>&1; then
        echo "test: grant ok (yardr $cmd)"
      else
        echo "test: grant FAILED: yardr refused 'yardr $cmd', which SETUP.md teaches: $(tail -c 300 "$core/out")"
        fail=1
      fi
    done <"$core/grants"
    # What those grants gave: read as the word for the read calls, and draft
    # as an update, granted by its name; nothing else was granted.
    yrun source grants --json >"$core/grants.json" 2>&1
    jq -e 'length > 0
           and ([.[] | .kinds | to_entries[]] | all((.key == "read" and .value == "read") or (.key == "draft" and .value == "update")))
           and ([.[] | .kinds | has("read")] | any) and ([.[] | .kinds | has("draft")] | any)' "$core/grants.json" >/dev/null
    ok "SETUP.md's grants give read as read and draft as an update, nothing else" $? "$(head -c 300 "$core/grants.json")"
  fi

  # -- the calls and the check, through the real yardr against the stand-in.
  # The operator's copy is the declaration with its base replaced: the real
  # one is https and Infomaniak's. The /api stays, so that the paths the
  # stand-in sees are the ones the service gets. The copy sits beside its
  # program, which source add copies into the yard.
  mkdir -p "$core/mine"
  cp "$here/build-draft" "$core/mine/"
  : >"$core/requests"
  printf '%s\n' "$token" >"$core/token"
  printf '{}\n' >"$core/answer.json"
  python3 -I testdata/standin.py "$core/port" "$core/requests" "$core/answer.json" "$core/token" 2>"$core/standin.err" &
  standin_pid=$!
  i=0
  while [ ! -s "$core/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
  if [ ! -s "$core/port" ]; then
    echo "test: core FAILED: the stand-in did not listen: $(cat "$core/standin.err")"
    fail=1
  else
    site=http://127.0.0.1:$(cat "$core/port")
    sed "s|^base: .*|base: $site/api|" "$here/infomaniak-mail.yaml" >"$core/mine/infomaniak-mail.yaml"
    # The token is given as SETUP.md gives it: yardr source key, with the
    # name on its standard input, which the server writes into the source's
    # env file.
    yrun source add "$core/mine/infomaniak-mail.yaml" >"$core/add.out" 2>&1 \
      && printf 'INFOMANIAK_MAIL_TOKEN=%s\n' "$token" | yrun source key infomaniak-mail >>"$core/add.out" 2>&1 \
      && yrun source set infomaniak-mail calls_per_hour=1000 >>"$core/add.out" 2>&1 \
      && yrun source set infomaniak-mail updates_per_hour=1000 >>"$core/add.out" 2>&1
    ok "the operator's copy is added and the token given by source key" $? "$(tail -c 300 "$core/add.out")"

    # -- the check call: mailboxes, with the token as a bearer token
    : >"$core/requests"
    cp testdata/answers/mailboxes.json "$core/answer.json"
    yrun source check infomaniak-mail >"$core/check.out" 2>&1
    rc=$?
    [ "$rc" = 0 ] && grep -q 'check call mailboxes (GET /mailbox): 200' "$core/check.out" && grep -q 'yardr@example.com' "$core/check.out" \
      && grep -q 'command call.draft.build (build-draft): can be run' "$core/check.out"
    ok "source check makes the mailboxes call with the token, shows the mailbox and says build-draft can be run" $? "exit $rc: $(tail -c 300 "$core/check.out")"
    check "the check call's request" "$core/requests" testdata/check.requests.golden

    # A case file is sections, each opened by a line "=== name": args (the
    # call and its parameters, one word to a line), answer (the stand-in's
    # body; a line "@name" stands for testdata/answers/name.json) and status
    # (its HTTP status, 200 if left out).
    for c in testdata/calls/*.case; do
      n=$(basename "$c" .case)
      d="$core/case_$n"
      mkdir "$d"
      awk -v dir="$d" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } { print >> f }' "$c"
      if [ -e "$d/answer" ]; then
        first=$(head -n 1 "$d/answer")
        case $first in
          @*) cp "testdata/answers/${first#@}.json" "$core/answer.json" ;;
          *) cp "$d/answer" "$core/answer.json" ;;
        esac
      else
        printf '{}\n' >"$core/answer.json"
      fi
      rm -f "$core/answer.json.status"
      [ ! -e "$d/status" ] || cp "$d/status" "$core/answer.json.status"
      : >"$core/requests"
      set --
      while IFS= read -r word; do set -- "$@" "$word"; done <"$d/args"
      yrun call infomaniak-mail "$@" --json >"$d/stdout" 2>"$d/stderr"
      status=$?
      {
        echo "--- requests"
        cat "$core/requests"
        echo "--- status"
        echo "$status"
        echo "--- stdout"
        if [ -s "$d/stdout" ]; then jq -S . "$d/stdout" 2>/dev/null || cat "$d/stdout"; fi
        echo "--- stderr"
        cat "$d/stderr"
      } >"$d/golden"
      check "call $n" "$d/golden" "testdata/calls/$n.golden"
    done
    rm -f "$core/answer.json.status"

    # Of all the requests the cases made: each is a read of one of the
    # service's lists or messages, or the one POST that writes a draft. No
    # other method, no other address: nothing sent, moved, marked, flagged,
    # archived, deleted, renamed or uploaded, and no draft replaced or removed.
    cat "$core"/case_*/golden >"$core/all.golden"
    grep -c '^GET ' "$core/all.golden" >/dev/null
    ok "the cases made reads" $?
    ! grep -E '^[A-Z]+ ' "$core/all.golden" | grep -vE '^(GET /api/mailbox\?with=[^ ]*|GET /api/mail/[0-9A-Za-z-]+/folder\?with=ik-static|GET /api/mail/[0-9A-Za-z-]+/folder/[A-Za-z0-9_=+%-]+/message(/[0-9]+)?\?[^ ]*|POST /api/mail/[0-9A-Za-z-]+/draft)$' | grep -q .
    ok "every request is a GET of the mailboxes, the folders, a folder's messages or one message, or a POST to a mailbox's draft" $?
    posts=$(grep -c '^POST ' "$core/all.golden")
    sed -n 's/^  body: //p' "$core/all.golden" >"$core/bodies"
    [ "$posts" -gt 0 ] && [ "$(grep -c . "$core/bodies")" = "$posts" ] && jq -s -e 'all(.[]; .action == "save" and .delay == 0)' "$core/bodies" >/dev/null
    ok "every POST ($posts) has a body, and every body is a draft: action save, no delay" $?
    ! grep -E '^  (accept|content-type|user-agent): ' "$core/all.golden" | grep -vE '^  (accept: application/json|content-type: application/json|user-agent: yardr)$' | grep -q .
    ok "every request asked for JSON, sent JSON where it had a body and named yardr as its User-Agent" $?
    ! grep -q 'auth: WRONG\|auth: missing' "$core/all.golden" testdata/check.requests.golden
    ok "every request carried the token as a bearer token" $?

    # -- the token is in the one file that holds it, and nowhere else
    kill "$standin_pid" 2>/dev/null
    standin_pid=
    envfile=$core/y/sources/infomaniak-mail.env
    grep -qF "$token" "$envfile"
    ok "the env file holds the token" $?
    leaks=$(grep -rlF -e "$token" "$core/y" "$core/h" 2>/dev/null | grep -vxF "$envfile")
    [ -z "$leaks" ]
    ok "the token is in no file of the yard but its env file" $? "found in: $leaks"
    ! grep -qF -e "$token" "$core/check.out" "$core/add.out" "$core/all.golden" "$core"/case_*/stdout "$core"/case_*/stderr
    ok "the token is in no output of the yard's commands" $?
  fi
fi
[ -z "${standin_pid:-}" ] || { kill "$standin_pid" 2>/dev/null; standin_pid=; }
{
  "about": "Infomaniak mail read and drafts. Lists mailboxes and folders, searches and reads mail, lists drafts, and writes drafts; sends nothing.",
  "base": "https://mail.infomaniak.com/api",
  "calls": [
    {
      "about": "List the mailboxes the token opens, each with its uuid (the mailbox every other call takes), its address and its aliases",
      "answer": {
        "max": 65536,
        "select": [
          "result",
          "data"
        ]
      },
      "kind": "read",
      "name": "mailboxes",
      "request": {
        "method": "GET",
        "path": "/mailbox",
        "query": [
          {
            "key": "with",
            "value": "aliases,permissions,accountId,count_users"
          }
        ]
      }
    },
    {
      "about": "List a mailbox's folders, each with its id and its role (INBOX, DRAFT, SENT, ARCHIVE, TRASH)",
      "answer": {
        "max": 65536,
        "select": [
          "result",
          "data"
        ]
      },
      "kind": "read",
      "name": "folders",
      "params": [
        {
          "log": true,
          "max": 64,
          "name": "mailbox",
          "pattern": "^[A-Za-z0-9_-]{1,64}$",
          "type": "string"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/mail/{mailbox}/folder",
        "query": [
          {
            "key": "with",
            "value": "ik-static"
          }
        ]
      }
    },
    {
      "about": "Search mail, one page of at most 50",
      "answer": {
        "max": 262144,
        "select": [
          "result",
          "data"
        ]
      },
      "kind": "read",
      "name": "search",
      "params": [
        {
          "log": true,
          "max": 64,
          "name": "mailbox",
          "pattern": "^[A-Za-z0-9_-]{1,64}$",
          "type": "string"
        },
        {
          "log": true,
          "max": 512,
          "name": "folder",
          "pattern": "^[A-Za-z0-9_=+/-]{1,512}$",
          "type": "string"
        },
        {
          "max": 1000,
          "name": "q",
          "optional": true,
          "type": "string"
        },
        {
          "max": 254,
          "name": "from",
          "optional": true,
          "type": "string"
        },
        {
          "max": 254,
          "name": "to",
          "optional": true,
          "type": "string"
        },
        {
          "max": 500,
          "name": "subject",
          "optional": true,
          "type": "string"
        },
        {
          "name": "since",
          "optional": true,
          "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
          "type": "string"
        },
        {
          "name": "before",
          "optional": true,
          "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
          "type": "string"
        },
        {
          "default": "1",
          "name": "everywhere",
          "optional": true,
          "type": "enum",
          "values": [
            "0",
            "1"
          ]
        },
        {
          "name": "maxResults",
          "pattern": "^([1-9]|[1-4][0-9]|50)$",
          "type": "string"
        },
        {
          "default": "0",
          "name": "offset",
          "optional": true,
          "pattern": "^[0-9]{1,6}$",
          "type": "string"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/mail/{mailbox}/folder/{folder}/message",
        "query": [
          {
            "key": "offset",
            "value": "{offset}"
          },
          {
            "key": "thread",
            "value": "off"
          },
          {
            "key": "severywhere",
            "value": "{everywhere}"
          },
          {
            "key": "limit",
            "value": "{maxResults}"
          },
          {
            "key": "scontains",
            "value": "{q}"
          },
          {
            "key": "sfrom",
            "value": "{from}"
          },
          {
            "key": "sto",
            "value": "{to}"
          },
          {
            "key": "ssubject",
            "value": "{subject}"
          },
          {
            "key": "sfromdate",
            "value": "{since}"
          },
          {
            "key": "stodate",
            "value": "{before}"
          }
        ]
      }
    },
    {
      "about": "Read one message, with its headers and its text",
      "answer": {
        "max": 1048576,
        "select": [
          "result",
          "data"
        ]
      },
      "kind": "read",
      "name": "message",
      "params": [
        {
          "log": true,
          "max": 64,
          "name": "mailbox",
          "pattern": "^[A-Za-z0-9_-]{1,64}$",
          "type": "string"
        },
        {
          "log": true,
          "max": 512,
          "name": "folder",
          "pattern": "^[A-Za-z0-9_=+/-]{1,512}$",
          "type": "string"
        },
        {
          "log": true,
          "max": 12,
          "name": "id",
          "pattern": "^[0-9]{1,12}$",
          "type": "string"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/mail/{mailbox}/folder/{folder}/message/{id}",
        "query": [
          {
            "key": "prefered_format",
            "value": "html"
          },
          {
            "key": "with",
            "value": "auto_uncrypt,thread_context"
          }
        ]
      }
    },
    {
      "about": "List drafts, one page of at most 50",
      "answer": {
        "max": 262144,
        "select": [
          "result",
          "data"
        ]
      },
      "kind": "read",
      "name": "drafts",
      "params": [
        {
          "log": true,
          "max": 64,
          "name": "mailbox",
          "pattern": "^[A-Za-z0-9_-]{1,64}$",
          "type": "string"
        },
        {
          "log": true,
          "max": 512,
          "name": "folder",
          "pattern": "^[A-Za-z0-9_=+/-]{1,512}$",
          "type": "string"
        },
        {
          "name": "maxResults",
          "pattern": "^([1-9]|[1-4][0-9]|50)$",
          "type": "string"
        },
        {
          "default": "0",
          "name": "offset",
          "optional": true,
          "pattern": "^[0-9]{1,6}$",
          "type": "string"
        }
      ],
      "request": {
        "method": "GET",
        "path": "/mail/{mailbox}/folder/{folder}/message",
        "query": [
          {
            "key": "offset",
            "value": "{offset}"
          },
          {
            "key": "thread",
            "value": "on"
          },
          {
            "key": "severywhere",
            "value": "0"
          },
          {
            "key": "limit",
            "value": "{maxResults}"
          }
        ]
      }
    },
    {
      "about": "Write a draft, never send one",
      "answer": {
        "max": 65536,
        "select": [
          "result",
          "data"
        ]
      },
      "build": [
        "build-draft"
      ],
      "kind": "update",
      "name": "draft",
      "params": [
        {
          "log": true,
          "max": 64,
          "name": "mailbox",
          "pattern": "^[A-Za-z0-9_-]{1,64}$",
          "type": "string"
        },
        {
          "log": true,
          "max": 254,
          "name": "from",
          "pattern": "^[^@[:space:]]+@[^@[:space:]]+$",
          "type": "string"
        },
        {
          "log": true,
          "max": 254,
          "name": "to",
          "pattern": "^[^@[:space:]]+@[^@[:space:]]+$",
          "type": "string"
        },
        {
          "log": true,
          "max": 254,
          "name": "cc",
          "optional": true,
          "pattern": "^[^@[:space:]]+@[^@[:space:]]+$",
          "type": "string"
        },
        {
          "max": 500,
          "name": "subject",
          "pattern": "^[^\\r\\n]*$",
          "type": "string"
        },
        {
          "max": 100000,
          "name": "body",
          "type": "string"
        },
        {
          "max": 998,
          "name": "in_reply_to",
          "optional": true,
          "pattern": "^[^[:space:]]+$",
          "type": "string"
        },
        {
          "max": 4000,
          "name": "references",
          "optional": true,
          "pattern": "^[^\\r\\n]*$",
          "type": "string"
        },
        {
          "max": 600,
          "name": "in_reply_to_uid",
          "optional": true,
          "pattern": "^[0-9]+@[A-Za-z0-9_=+/-]+$",
          "type": "string"
        }
      ],
      "request": {
        "method": "POST",
        "path": "/mail/{mailbox}/draft"
      }
    }
  ],
  "check": "mailboxes",
  "credential": {
    "env": "INFOMANIAK_MAIL_TOKEN",
    "header": "Authorization: Bearer {credential}"
  },
  "name": "infomaniak-mail"
}
# A stand-in for Infomaniak's mail API, for the check and the calls the test
# makes through the real yardr. It answers every request with the body and
# status the test has left in its answer files, read again for each request,
# so one process serves every case in turn. It records each request in the
# log it is given: the method, and the path and query exactly as they
# arrived, whether Authorization held the bearer token the test typed into the
# scratch yard (ok, WRONG for another token, missing for none), the Accept,
# Content-Type and User-Agent headers, and the JSON body with its keys in
# order. Any method is recorded, so that a request that is not the one
# expected shows in a golden.
#
# The token is not in the log: it is read from the token file, where the test
# wrote it, and compared there. It writes the port it bound, on 127.0.0.1, to
# the file it is given once it listens.
#
#   python3 -I standin.py <port file> <request log> <answer file> <token file>
#
# The status is the first line of <answer file>.status, 200 if there is no
# such file.
import http.server
import json
import os
import sys

port_file, log_file, answer_file, token_file = sys.argv[1:5]
with open(token_file) as f:
    token = f.readline().strip()


def bearer(header):
    """Returns "ok", "WRONG" or "missing"."""
    if not header or not header.startswith("Bearer "):
        return "missing"
    return "ok" if header[len("Bearer "):] == token else "WRONG"


class Handler(http.server.BaseHTTPRequestHandler):
    def handle_any(self):
        length = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(length) if length else b""
        lines = ["%s %s" % (self.command, self.path), "  auth: %s" % bearer(self.headers.get("Authorization"))]
        for name in ("Accept", "Content-Type", "User-Agent"):
            if self.headers.get(name) is not None:
                lines.append("  %s: %s" % (name.lower(), self.headers.get(name)))
        if raw:
            try:
                lines.append("  body: " + json.dumps(json.loads(raw), separators=(",", ":")))
            except ValueError:
                lines.append("  body: NOT JSON")
        with open(log_file, "a") as f:
            f.write("\n".join(lines) + "\n")
        status = 200
        if os.path.exists(answer_file + ".status"):
            with open(answer_file + ".status") as f:
                status = int(f.readline())
        with open(answer_file, "rb") as f:
            body = f.read()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = handle_any

    def log_message(self, *args):
        pass


server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w") as f:
    f.write(str(server.server_address[1]))
server.serve_forever()