rand[om]

rand[om]

med ∩ ml

Using Apple Notes as a CMS

Intro

I was exploring different ways I could use Apple Notes as a CMS (Content Management System). I found out that you can add a Google account to sync some notes, but those notes are synced using the IMAP protocol to a folder in the email account. This post explains how to use the Gmail API to retrieve those notes. The idea for the CMS is:

  1. Write notes in Apple Notes, avoid using tables and drawings (there may be other elements that are not compatible with this method)
  2. Add a new Google account and synchronize the notes
  3. Duplicate the note you want to sync and move it to the Google account folder in the Notes app.
  4. Use the Gmail API to retrieve the note programatically from another server.

My main objective was being able to read my notes from a linux computer that doesn’t have access to iCloud. This is how I did it.

Create a new email account

I would recommend creating a new account only for this. We will be getting some keys to access the emails programatically. This will ensure that losing those keys doesn’t mean giving someone access to your email.

Create a new access key

Go to https://console.cloud.google.com/ and create a new project. I called mine “apple-notes-blog-cms”. Go back to the cosole start page and make sure the new project is selected in the top left dropdown.

Now, on the top-left hamburguer menu, go to APIs & Services > Credentials. Click on “Create Credentials” and select “OAuth Client ID”. It will ask you to first Configure the consent screen, this is not very important because you’ll be the only one using the app. Select “External” and fill in the data. Do not add any scopes and then add your email as a test user. Then click “Save and Continue” and move to the end of the process.

Now we’ve configured the consent screen and can click on “Create Credentials” > “OAuth Client ID” again. In “Application Type” select “Desktop App” and give it a name. Download the JSON secret and move it to your project’s folder, rename it to credentials.json.

Now, in the same section. Go to “Enabled APIs & Services”, search for “Gmail” and enable it.

Code

First, install the necessary dependencies in a virtual environment. requirements.txt:

python3 -m pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib

Now we can authorize the credentials and generate and auth token using the example code provided by the Google documentation.

auth.py:

import os.path

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

# If modifying these scopes, delete the file token.json.
SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']


def main():
    """Shows basic usage of the Gmail API.
    Lists the user's Gmail labels.
    """
    creds = None
    # The file token.json stores the user's access and refresh tokens, and is
    # created automatically when the authorization flow completes for the first
    # time.
    if os.path.exists('token.json'):
        creds = Credentials.from_authorized_user_file('token.json', SCOPES)
    # If there are no (valid) credentials available, let the user log in.
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                'credentials.json', SCOPES)
            creds = flow.run_local_server(port=0)
        # Save the credentials for the next run
        with open('token.json', 'w') as token:
            token.write(creds.to_json())

    try:
        # Call the Gmail API
        service = build('gmail', 'v1', credentials=creds)
        results = service.users().labels().list(userId='me').execute()
        labels = results.get('labels', [])

        if not labels:
            print('No labels found.')
            return
        print('Labels:')
        for label in labels:
            print(label['name'])

    except HttpError as error:
        # TODO(developer) - Handle errors from gmail API.
        print(f'An error occurred: {error}')


if __name__ == '__main__':
    main()

Before running that script, make sure you haven’t blocked access to localhost from your browser (some uBlock Origin filters do). Otherwise you won’t be able to authenticate.

When you run the script, a new browser window will open (or you’ll get a URL to copy). Log in with your email accout to generate the auth token. When the process finishes, there will be a new token.json file.

Now we can access our Gmail data from Python, let’s write the code to access our notes.

Accessing notes

Now we can use the gmail service to retrieve our emails. The emails are formatted using HTML, I’m using html2text to convert the message body to Markdown. The following code is explained in the code comments.

main.py:

import base64
import os.path

import html2text
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

# If modifying these scopes, delete the file token.json.
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]

# Same function as in the script above, but it returns the service object.
def get_svc():
    """
    Get a service object to interact with Gmail.
    """

    creds = None

    if not os.path.exists("token.json"):
        raise FileNotFoundError("token.json")
    creds = Credentials.from_authorized_user_file("token.json", SCOPES)
    # Either refresh the token or ask the user to run the authentication script
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            raise SystemExit("Please run the auth.py script")

    service = build("gmail", "v1", credentials=creds)

    return service


service = get_svc()

# First, get the label ID of the notes folder
labels = service.users().labels().list(userId="me").execute()
label_id = [x["id"] for x in labels["labels"] if x["name"] == "Notes"][0]

print(label_id)

# Now we can get the emails from that folder/label

msgs = service.users().messages().list(labelIds=label_id, userId="me").execute()

for msg in msgs["messages"]:
    msg_id = msg["id"]

    res = service.users().messages().get(userId="me", id=msg_id).execute()
    data = res["payload"]["body"]["data"]

    html_msg = base64.urlsafe_b64decode(data)

    print(msg_id)
    print(html2text.html2text(html_msg.decode()))

Outro

After all this, I ended up moving from Apple Notes to Obsidian 1. I didn’t want to parse and transform the HTML messages, attached images, etc. I was already using a Notion to Markdown tool I wrote and I wasn’t satisfied either, so I decided I wanted to keep all my notes as local Markdown files.

Even though I didn’t use this technique to publish any blog post, I thought it could be useful to share what I learned. At least I had no idea you could sync Apple Notes to an email account!