Updating Jira space emails in bulk
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
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()