Updating Jira space emails in bulk

Still need help?

The Atlassian Community is here for you.

Ask the community

Platform Notice: Cloud - This article applies to Atlassian products on the cloud platform.

   

Summary

It may be desirable to update Jira space emails. This can be done manually in the UI - Configure email notifications - however, this process does not scale well for many spaces.

Below is a Python script which may be modified to meet needs.


This script uses the Jira Cloud REST API to export all Jira spaces/projects to a csv file, including:

  • Space key
  • Space name
  • ID
  • Space type (software / service_desk / business)
  • Management type (team‑managed / company‑managed / unknown)

and then bulk update the space email address for all rows in that CSV.

It’s designed so you can review and edit the CSV before running the update.

Prerequisites

  • Python 3.x
  • requests ( pip install requests )
  • A Jira Cloud user with permission to:
    • View all in-scope spaces
    • Update space email addresses
  • An Atlassian API token for that user - see See Manage API tokens for your Atlassian account

Configuration

Edit the top of the script:

JIRA_BASE_URL = "https://yoursite.atlassian.net"  # no trailing slash
JIRA_EMAIL = "your@email.com"
JIRA_API_TOKEN = "API-TOKEN"
NEW_SPACE_EMAIL = "space@email.com"

Usage

The script has two main modes, controlled by CLI flags:

  • --get-spaces – export spaces to csv
  • --update – read CSV and update email addresses


Export spaces to CSV

python jira_spaces_email_update.py --get-spaces


This will create a CSV like:

key,name,id,space_type,managementType
ABC,ABC Project,10000,software,company-managed
SERV,Service Desk,10001,service_desk,team-managed


You can now open this file and:

  • Filter by space_type (e.g. only software or service_desk)
  • Filter by managementType (e.g. only company-managed)

Delete any rows you don’t want to update.

Bulk update space email addresses

After reviewing/editing the csv:

python jira_spaces_email_update.py --update


You’ll be prompted to confirm the email address.


The script will:

  • Read each row
  • Take the space key (column 1) and ID (column 3)
  • Call PUT <JIRA_CLOUD_URL>/rest/api/3/project/{id}/email to set the new email

Updates are done by space ID (from the CSV), but the log messages also show the space key for clarity.

If the API call for a given space fails, the script logs the error and continues with the next one.


Code

jira_spaces_email_update.py
import argparse
import base64
import csv
import sys
from pathlib import Path

import requests

# ========= 1. CONFIG / AUTH =========

# Substitute these values
JIRA_BASE_URL = "https://yoursite.atlassian.net"  # no trailing slash
JIRA_EMAIL = "your@email.com"
JIRA_API_TOKEN = "API-TOKEN"                      # See https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/
NEW_SPACE_EMAIL = "space@email.com"

# Where to store the interim CSV
CSV_PATH = Path("jira_spaces.csv")

# Basic auth header for Jira Cloud (email + API token)
auth_str = f"{JIRA_EMAIL}:{JIRA_API_TOKEN}".encode("utf-8")
AUTH_HEADER = base64.b64encode(auth_str).decode("utf-8")

HEADERS = {
    "Authorization": f"Basic {AUTH_HEADER}",
    "Accept": "application/json",
    "Content-Type": "application/json",
}

# ========= 2. EXPORT ALL SPACES TO CSV =========

def derive_management_type(space: dict) -> str:
    """
    Derive management type from Jira space fields
      - 'simplified': True  -> team-managed
      - 'simplified': False -> company-managed
    """
    simplified = space.get("simplified")
    if simplified is True:
        return "team-managed"
    if simplified is False:
        return "company-managed"
    # Fallback if field missing/unknown
    return "unknown"


def export_spaces_to_csv(csv_path: Path):
    """
    Calls GET /rest/api/3/project/search to retrieve all spaces,
    then writes a CSV with columns:
      key, name, id, space_type, management_type
    """
    print("Retrieving spaces from Jira...")

    start_at = 0
    max_results = 50
    all_spaces = []

    while True:
        url = f"{JIRA_BASE_URL}/rest/api/3/project/search"
        params = {
            "startAt": start_at,
            "maxResults": max_results,
            # You can add filters here if needed, e.g.:
            # "projectTypeKey": "software",
        }
        resp = requests.get(url, headers=HEADERS, params=params)
        
        if resp.status_code != 200:
            print(f"Failed to fetch spaces: {resp.status_code} {resp.text}")
            sys.exit(1)

        data = resp.json()
        values = data.get("values", [])
        if not values:
            break

        all_spaces.extend(values)
        start_at += len(values)

        if start_at >= data.get("total", 0):
            break

    if not all_spaces:
        print("No spaces returned from Jira.")
        return

    # Write CSV
    with csv_path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f)
        writer.writerow(
            [
                "key",
                "name",
                "id",
                "space_type",     # software / service_desk / business
                "managementType",  # team-managed / company-managed / unknown
            ]
        )
        for p in all_spaces:
            key = p.get("key")
            name = p.get("name")
            pid = p.get("id")
            space_type = p.get("projectTypeKey")
            management_type = derive_management_type(p)

            writer.writerow(
                [
                    key,
                    name,
                    pid,
                    space_type,
                    management_type,
                ]
            )

    print(f"Wrote {len(all_spaces)} spaces to {csv_path}")


# ========= 3. LOOP THROUGH CSV AND UPDATE SPACE EMAIL =========

def update_space_email(space_id: int, new_email: str, space_key: str):
    """
    Calls PUT /rest/api/3/project/{projectId}/email
    to update the space email address.
    """
    url = f"{JIRA_BASE_URL}/rest/api/3/project/{space_id}/email"
    payload = {
        "emailAddress": new_email
    }

    resp = requests.put(url, headers=HEADERS, json=payload)

    if resp.status_code not in (200, 204):
        print(
            f"Failed to update email for space {space_key} (id: {space_id}): "
            f"{resp.status_code} {resp.text}"
        )
        return False

    print(f"Updated email for space {space_id} to {new_email}")
    return True


def update_all_spaces_from_csv(csv_path: Path, new_email: str):
    """
    Reads the CSV (expects first column to be space key),
    then updates each space's email address.
    """
    if not csv_path.exists():
        print(f"CSV file not found: {csv_path}")
        sys.exit(1)

    with csv_path.open("r", newline="", encoding="utf-8") as f:
        reader = csv.reader(f)
        header = next(reader, None)  # skip header

        for row in reader:
            if not row:
                continue
            space_key = row[0]
            space_id = row[2]
            if not space_key or space_key.lower() == "key":
                continue

            update_space_email(space_id, new_email, space_key)


# ========= 4. CLI HANDLING =========

def parse_args():
    parser = argparse.ArgumentParser(
        description="Jira Cloud: export spaces and/or update space email addresses."
    )
    parser.add_argument(
        "--get-spaces",
        action="store_true",
        help="Fetch all spaces and write them to a CSV file.",
    )
    parser.add_argument(
        "--update",
        action="store_true",
        help="Read the CSV file and update space email addresses.",
    )
    parser.add_argument(
        "--csv-path",
        default=str(CSV_PATH),
        help=f"Path to the CSV file (default: {CSV_PATH})",
    )
    parser.add_argument(
        "--email",
        default=NEW_SPACE_EMAIL,
        help=f"New space email address to set (default: {NEW_SPACE_EMAIL})",
    )
    return parser.parse_args()


def main():
    args = parse_args()
    csv_path = Path(args.csv_path)

    if not args.get_spaces and not args.update:
        print("You must specify at least one of: --get-spaces or --update")
        sys.exit(1)

    if args.get_spaces:
        export_spaces_to_csv(csv_path)

    if args.update:
        # Optional confirmation prompt before bulk update
        confirm = input(
            f"\nAbout to update email for all spaces in {csv_path} "
            f"to '{args.email}'. Continue? [y/N]: "
        ).strip().lower()

        if confirm == "y":
            update_all_spaces_from_csv(csv_path, args.email)
        else:
            print("Aborted without updating space emails.")


if __name__ == "__main__":
    main()




Last modified on Feb 9, 2026

Was this helpful?

Yes
No
Provide feedback about this article
Powered by Confluence and Scroll Viewport.