Contacts
`contacts.list`, `get`, `create`, `save`, `update`, `set_audiences`, `delete`, `delete_many`, `list_people`, `set_photo`, `remove_photo`, `block`, `unblock`, `list_threads` and `activity`.
Every method
from openemail import openemail page = openemail.contacts.list(limit=100)contact = openemail.contacts.get('[email protected]') saved = openemail.contacts.create({ 'email': '[email protected]', 'name': 'Grace Hopper', 'notes': 'Met at the compiler workshop',}) openemail.contacts.update(saved['email'], {'notes': None})openemail.contacts.set_audiences(saved['email'], { 'audienceIds': ['aud_4c1b8e2a7d9f05c36b4e8a71'],})openemail.contacts.delete(saved['email']) print(len(page['items']), page['hasMore'], contact['source'], contact['lastSeenAt'])Most recently seen first, with contacts that have never been mailed last. source is auto when the row was written because a member sent that address a message from the app composer, which is a materially different claim from somebody having saved it. Mail arriving from an address writes nothing, and neither does a send through this API.
The book belongs to the workspace rather than to one person, so a contact saved by any member is the contact every member and every key sees. create writes source as manual and puts the contact in the default audience as it is written. Name lists of your own in audienceIds to join them in the same call, which also needs audiences:write, or add the contact later with openemail.audiences.add_contact. set_audiences says exactly which lists a contact is in, in one call.
Addresses are stored lowercased and the client encodes the one you pass, so [email protected] reaches the right row. The address is the identity, so update cannot change it: moving a contact is a delete and a create.
Parameters: contacts.list
limitint- How many contacts to return per page: an integer from 1 to 200, defaulting to 50. It is coerced, so `'100'` off a query string is fine, and a value outside the range is a 422 rather than a clamped one.
cursorstr- The `nextCursor` from the previous page. Never build one yourself: a cursor naming a contact that no longer exists is a 400 `invalid_cursor`, which means your paging state is stale and the walk should restart without a cursor.
sourceContactSource- `'manual'` for the contacts somebody saved on purpose, `'auto'` for the ones the app composer recorded. Leave it out for the whole book.
qstr- Searches the name and the address, up to 200 characters. When nothing matches exactly on the first page, close spellings are returned instead, and the pages that follow keep matching the same way.
Response: ContactResource
contacts.list returns a Page[ContactResource], so the rows are on page['items'] and the walk follows page['nextCursor'] while page['hasMore'] is True, which list_all and iterate do for you. get, create, save, update, set_audiences, set_photo and remove_photo each return one ContactDetailResource, the same row plus audiences. The address book is unbounded, which is why this route pages rather than returning a list that silently stopped at 200.
objectLiteral['contact']- Always the string `contact`, on the list rows as well as on `get`.
emailstr- The address, lowercased on write so `[email protected]` and `[email protected]` are one contact, and the key every contacts method takes, since no contact id is exposed. Rows belong to the workspace rather than to the member or the key that wrote them, so every member and every key on the workspace reads and writes one address book.
namestr | None- `None` when no name has ever been recorded for the address. An automatic write carries one only when the header supplied something other than the address itself, and it can never overwrite a name the user typed.
sourceContactSource | str- `auto` means the row was written because the user sent mail to that address; `manual` means somebody entered it by hand, a materially different claim, and an upsert never downgrades `manual` back to `auto`. Mail arriving from an address writes no row at all, deliberately, so somebody who has only ever written to you is not in here; the union stays open because the column is free text defaulting to `manual`.
notesstr | None- Free text somebody wrote about this person, in the app or through `update`, never generated. `None` when nobody has written any, and an explicit `None` on `update` clears it.
lastSeenAtstr | None- ISO-8601 UTC, bumped every time a member sends to that address from the app composer, not when mail arrives from it, which writes nothing. `None` on a contact saved through `create` that has never been mailed, and those sort last in the descending `lastSeenAt` order this route returns.
audienceslist[ContactAudienceResource]- Only on `get`, `create`, `save`, `update`, `set_audiences`, `set_photo` and `remove_photo`, never on list rows. Every audience the contact is in, as a dict with `id`, `name` and `builtin`, the default one included. `builtin` is `default` on the audience every contact belongs to and `None` on one somebody created, so branch on it rather than on the name, which anybody can change.
photoUrlstr | None- Where the contact photo is served, or `None` when the contact has none. `set_photo` sets it and every upload gets a new URL.
Setting a contact's audiences
set_audiences(email, {'audienceIds': [...]}) says exactly which audiences one contact is in, in one request. The contact joins every audience listed that it is not in yet and leaves every other one, and the call returns the ContactDetailResource after the change. It needs audiences:write, because it writes memberships rather than the contact, and repeating it changes nothing.
The default audience is always kept, so {'audienceIds': []} leaves the contact in the default audience alone. It takes up to 100 ids. An id that names no audience in this workspace is a 404 audience_not_found and nothing changes, and an address that is not a contact is a 404 contact_not_found.
Everyone on the Contacts page
list_people lists the people the Contacts page in the app shows: the saved contacts and every address seen in mail, each with saved, threads and lastAt. list is the saved contacts alone. The addresses seen in mail come only when the key also holds threads:read, and page['seen'] says whether they did. sort is recent, name or threads, q searches names, addresses and notes, and blocked=True keeps the people the workspace blocklist blocks, whole-domain rules included. blockedBy names the rule on every row.
from openemail import openemail page = openemail.contacts.list_people(sort='threads', limit=50) for person in page['items']: if not person['saved'] and (person['threads'] or 0) > 5: openemail.contacts.save(person['email']) blocked = openemail.contacts.list_all_people(blocked=True)list_all_people and iterate_people walk every page. The cursor is opaque, so pass nextCursor back as it came, with the same sort, q and blocked.
Saving, deleting and photos
save(email, {'name': ..., 'notes': ...}) is Add to contacts and Keep in contacts: it saves an address that is not a contact yet, keeps one recorded from a send as saved by hand, and brings back a deleted one. delete is Delete: it removes the saved contact and hides the address, so the composer does not record it again, and it takes an address only ever seen in mail too. wasSaved says which it was. delete_many deletes up to 200 in one call.
from pathlib import Path from openemail import openemail openemail.contacts.save('[email protected]', {'name': 'Grace Hopper'}) photo = Path('grace.jpg').read_bytes()contact = openemail.contacts.set_photo('[email protected]', photo, content_type='image/jpeg') openemail.contacts.remove_photo('[email protected]')openemail.contacts.delete_many(['[email protected]', '[email protected]'])set_photo sends the image bytes as they are: PNG, JPEG, WebP or GIF up to 5 MB, fitted into a 512 pixel square. Pass content_type=, because bytes carry no type of their own: without it the upload goes as application/octet-stream, which is refused with a 422 invalid_image. The address has to be a saved contact first.
Blocking
block(email) puts the address on the workspace blocklist so mail from it is refused, dropping any plus tag, and unblock(email) takes off every rule that blocks it. Both need settings:write, because they change the blocklist rather than the contact, and neither needs the address to be a contact.
When unblock lifts a whole-domain rule, removed lists it with list set to blockedDomains, and everybody at that domain is unblocked with it.
Conversations and activity
list_threads(email) pages through the threads the address wrote or was written to, in every folder, and list_all_threads and iterate_threads walk them. activity(email) returns the numbers behind a contact's Activity tab: received and sent per bucket, threads waiting on your reply, and the median reply time each way. Both need threads:read.
import time from openemail import openemail threads = openemail.contacts.list_threads('[email protected]', q='invoice') activity = openemail.contacts.activity( '[email protected]', minutes=30 * 24 * 60, grain='day', offset_minutes=time.localtime().tm_gmtoff // 60,) print(len(threads['items']), activity['totals']['waiting'])Reference
contacts.list()Full referencecontacts.list_all()Full referencecontacts.iterate()Full referencecontacts.get()Full referencecontacts.create()Full referencecontacts.save()Full referencecontacts.update()Full referencecontacts.set_audiences()Full referencecontacts.delete()Full referencecontacts.delete_many()Full referencecontacts.list_people()Full referencecontacts.list_all_people()Full referencecontacts.iterate_people()Full referencecontacts.set_photo()Full referencecontacts.remove_photo()Full referencecontacts.block()Full referencecontacts.unblock()Full referencecontacts.list_threads()Full referencecontacts.list_all_threads()Full referencecontacts.iterate_threads()Full referencecontacts.activity()Full reference