The GP Booking App provides two email channels:
| Channel | Purpose | Status |
|---|---|---|
| Clinician Email Integration | Individual clinician inbox access via Microsoft Graph OAuth | ✅ Working |
| Practice Shared Mailbox | Invoice email sending from practice mailboxes (e.g. billing@clinic.co.uk) via Microsoft Graph application permissions |
✅ Working |
| Per-Practice SMTP | Fallback for invoice emailing when no Graph-connected mailbox is available | ✅ Working (fallback) |
Clinicians sign into their own Microsoft 365 / Outlook account via OAuth 2.0. The app stores the access/refresh tokens in a UserImapAccount record (one-to-one with the user) and uses the Microsoft Graph API to:
GET /me/messages)POST /me/sendMail)This is accessed from the Clinician Dashboard (/dashboard/clinician/) via the Email section or directly at /integrations/imap/compose/.
User (Clinician) ──OAuth 2.0──► Microsoft Azure AD ──token──► Graph API
│ │
▼ ▼
UserImapAccount (db) /me/messages
├─ oauth_token (encrypted) /me/sendMail
├─ oauth_refresh_token
└─ oauth_token_expiry
| File | Purpose |
|---|---|
integrations/models.py |
UserImapAccount model (lines ~518) and PracticeMailbox model (lines ~586) |
integrations/views.py |
OAuth views, setup/teardown, compose view |
integrations/tasks.py |
Token refresh, inbox fetching, app-only sending (_get_app_only_token, send_via_graph_app_only) |
integrations/api/views.py |
ImapInboxAPIView (GET inbox), ImapComposeAPIView (POST send via delegated auth) |
integrations/api/urls.py |
API routes (imap/inbox, imap/compose) |
dashboards/views/dashboards.py |
AccountsComposeEmailView, AccountsSendEmailAPIView (shared mailbox sending) |
dashboards/urls.py |
Dashboard routes including compose |
App name: gp booking app
App ID (Client ID): edac5c71-4dd7-47f5-83ee-9b2f9d02407c
Tenant ID: f0fcc627-aebb-4fa3-aac8-0f9d08b38ef4
Client Secret: stored in environment as AZURE_AD_CLIENT_SECRET
| Permission | Type | Purpose |
|---|---|---|
User.Read |
Delegated | Read user profile |
Mail.Read |
Delegated | Read inbox |
Mail.Send |
Delegated | Send emails from /me/sendMail |
| Permission | Type | Purpose |
|---|---|---|
Mail.Send |
Application | Send email from any mailbox via client credentials flow |
Why Application permission? The
/users/{mailbox}/sendMailendpoint requires either the user to have delegate access to the shared mailbox, or the app to have theMail.SendApplication permission. The delegated approach consistently returned403 Access Denieddespite the user having FullAccess and SendAs permissions. Application permissions (app-only / client credentials flow) resolve this entirely.
https://test-client.gp.veripath.co.uk/integrations/oauth2/callback/
https://test-client.gp.veripath.co.uk/integrations/practice-mailbox/oauth2/callback/
# dsp_clinic/settings.py
AZURE_AD_CLIENT_ID = env('AZURE_AD_CLIENT_ID')
AZURE_AD_CLIENT_SECRET = env('AZURE_AD_CLIENT_SECRET')
AZURE_AD_TENANT_ID = env('AZURE_AD_TENANT_ID')
AZURE_AD_REDIRECT_URI = env('AZURE_AD_REDIRECT_URI',
default='https://test-client.gp.veripath.co.uk/integrations/oauth2/callback/')
AZURE_AD_PRACTICE_MAILBOX_REDIRECT_URI = env('AZURE_AD_PRACTICE_MAILBOX_REDIRECT_URI',
default='https://test-client.gp.veripath.co.uk/integrations/practice-mailbox/oauth2/callback/')
AZURE_AD_SCOPES = ['User.Read', 'Mail.Read', 'Mail.Send']
AZURE_AD_AUTHORITY = 'https://login.microsoftonline.com/{tenant_id}'
Set in config/secrets/env.dev and config/secrets/env.production:
AZURE_AD_TENANT_ID=f0fcc627-aebb-4fa3-aac8-0f9d08b38ef4
AZURE_AD_CLIENT_ID=edac5c71-4dd7-47f5-83ee-9b2f9d02407c
AZURE_AD_CLIENT_SECRET=mPm8Q~QQofNZwtAmoCsUhIvaEqYpUDQTFBt7Xcpv
The UserImapAccount model went through two migrations:
| Migration | Description |
|---|---|
0008_user_imap_account |
Created model with IMAP fields (server, port, password) — original approach |
0009_oauth2_migration |
Migrated from IMAP to OAuth2 — removed IMAP fields, added OAuth token fields |
0010_practice_mailbox_model |
Created PracticeMailbox model for shared mailboxes |
All three are applied in the database. Migration files are in integrations/migrations/.
Allow clinics to send invoice emails from a shared mailbox (e.g. billing@clinic.co.uk, accounts@clinic.co.uk) rather than from a clinician's individual account. Used by the accounts dashboard compose page and for automated shortfall invoicing.
Two sending paths are used:
AccountsSendEmailAPIView (POST /dashboard/accounts/api/send-email/)
|
|-1. Practice has PracticeMailbox connected?
| |- YES -> send_via_graph_app_only(mailbox_email, to, subject, html_body, attachments=[PDF])
| | `- Client credentials token -> POST /users/{mailbox}/sendMail
| `- NO -> send_invoice_email(invoice, pdf_buffer)
| `- Per-practice SMTP -> Fallback system SMTP
|
`-2. Returns JSON {status: "SENT"} or error
The Graph API call to POST /users/{shared-mailbox}/sendMail with a delegated token consistently returned 403 Access Denied, even though:
Mail.Send delegated permission/me/sendMail worked perfectly (individual user sending)POST /users/billing@surenode.co.uk/sendMail returned 403 ErrorAccessDeniedExchange Online PowerShell confirmed permissions were correctly set. The permissions were in place but Graph API still denied delegated access. This is a known limitation - delegating cross-user mailbox access via Graph requires carefully configured Exchange permissions that don't always propagate.
The solution was to add Mail.Send as an Application permission (not delegated) and use the client credentials flow (app-only, no user context) for shared mailbox sending.
Steps taken:
Mail.Send Application permission to the gp booking app app registration_get_app_only_token() and send_via_graph_app_only() in integrations/tasks.pyAccountsSendEmailAPIView to use app-only Graph when a PracticeMailbox is connecteddef _get_app_only_token():
app = msal.ConfidentialClientApplication(
client_id=settings.AZURE_AD_CLIENT_ID,
client_credential=settings.AZURE_AD_CLIENT_SECRET,
authority=settings.AZURE_AD_AUTHORITY,
)
result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])
return result.get("access_token")
def send_via_graph_app_only(mailbox_email, to, subject, body,
content_type="HTML", attachments=None):
token = _get_app_only_token()
message = {"subject": subject,
"body": {"contentType": content_type, "content": body},
"toRecipients": [{"emailAddress": {"address": to}}]}
if attachments:
message["attachments"] = [{"@odata.type": "#microsoft.graph.fileAttachment", ...}]
resp = requests.post(
f"https://graph.microsoft.com/v1.0/users/{mailbox_email}/sendMail",
headers={"Authorization": f"Bearer {token}"},
json={"message": message, "saveToSentItems": True},
)
return resp.status_code in (202, 200, 201), None
User (Billing Admin)
|
|-1. Click "Connect Clinic Email" or "Connect Another Mailbox"
| -> Microsoft login page
|
|-2. Microsoft redirects back to callback URL
| -> Stores tokens temporarily in session
|
|-3. User enters shared mailbox email (e.g. billing@clinic.co.uk)
| -> Creates PracticeMailbox record with tokens
|
`-4. Invoice sending uses send_via_graph_app_only()
-> Client credentials token (NOT user's delegated token)
-> POST /users/{shared-mailbox}/sendMail
| View | URL | Purpose |
|---|---|---|
PracticeMailboxSetupView |
/integrations/practice-mailbox/ |
List connected mailboxes |
PracticeMailboxOAuthInitiateView |
/integrations/practice-mailbox/oauth2/initiate/<practice_id>/ |
Start OAuth |
PracticeMailboxOAuthCallbackView |
/integrations/practice-mailbox/oauth2/callback/ |
Handle callback |
PracticeMailboxFinishView |
/integrations/practice-mailbox/finish/ |
Enter mailbox address |
PracticeMailboxDisconnectView |
/integrations/practice-mailbox/<pk>/disconnect/ |
Remove connection |
If no PracticeMailbox is connected (or Graph sending fails), the system falls back to per-practice SMTP via PracticeSmtpConfig. Management interface at /dashboard/admin/ - Email Server Configurations.
AccountsComposeEmailViewLocated at /dashboard/accounts/compose/?practice=<id>.
Features:
POST /dashboard/accounts/api/send-email/ accepts JSON:
{
"invoice_id": 123,
"to": "patient@example.com",
"subject": "Invoice INV-2026-00004",
"body": "<h2>Dear Patient</h2><p>Please find your invoice...</p>",
"attach_pdf": true
}
Sending flow:
PracticeMailbox -> send_via_graph_app_only() (Graph app-only with PDF attachment, HTML body)send_invoice_email() (SMTP fallback)An Email section was added to the clinician dashboard (/dashboard/clinician/) with:
/integrations/imap/compose/| Feature | Status | Notes |
|---|---|---|
| Clinician OAuth sign-in | Working | Delegated auth, /me/sendMail |
| Clinician compose email | Working | Via ImapComposeAPIView, /me/sendMail |
| Shared mailbox connection | Working | OAuth flow via PracticeMailbox views |
| Shared mailbox sending | Working | Fixed: app-only auth with Mail.Send Application permission |
| PDF attachment via Graph | Working | Base64-encoded file attachment |
| HTML body via Graph | Working | contentType: "HTML" |
| SMTP fallback | Working | Practices without Graph use SMTP |
| Admin SMTP config UI | Working | Manage per-practice SMTP servers |
This section is for clinicians and practice staff who need to connect their email to the app.
Follow these steps to connect your Microsoft 365 / Outlook email so you can send emails from the clinician dashboard.
Step 1: Go to your Clinician Dashboard
Open your browser and go to /dashboard/clinician/. Look for the Email section.
Step 2: Click "Connect Your Email"
If you haven't connected an account yet, click the Connect Your Email button. This takes you to the email setup page.
Step 3: Click "Sign in with Microsoft"
On the setup page, you'll see a button with the Microsoft logo. Click it. A Microsoft login window will appear.
Step 4: Sign in with your work email
Enter your clinic/work email address and password (the one you use for Microsoft 365 or Outlook).
Step 5: Approve the permissions
Microsoft will ask you to approve a few permissions. The app needs to:
Click Accept to continue.
Step 6: You're all set!
You'll be returned to the app and your email address will appear on the page. You'll also see a Disconnect button - this means you're connected.
Step 7: Send your first email
From the clinician dashboard, click Compose Email. Fill in:
Need to disconnect? Click the Disconnect button on the setup page. This removes your Microsoft connection. You can reconnect anytime by signing in again.
This section is for practice managers or billing staff who need to connect a shared mailbox (like accounts@yourclinic.co.uk) for sending invoice emails.
Before you start, make sure you have:
billing@yourclinic.co.uk or accounts@yourclinic.co.uk)Step 1: Go to the Accounts Dashboard
Navigate to /dashboard/accounts/. You'll see a section called Clinic Email.
Step 2: Click "Connect Clinic Email"
Click the button that says Connect Clinic Email. A Microsoft login window will open.
Step 3: Sign in with your own work email
Sign in using your own email address (not the shared mailbox). This must be an account that has permissions on the shared mailbox.
Step 4: Approve the permissions
Microsoft will ask you to approve some permissions. Click Accept.
Step 5: Enter the shared mailbox address
After signing in, you'll be asked to type in the shared mailbox email address (e.g. billing@yourclinic.co.uk). Enter it and click Finish.
Step 6: Check the connection
You should now see the shared mailbox email address listed in the Clinic Email section with a green Connected badge.
Step 7: Send an invoice email
Click Compose Email to open the invoice email editor.
The email will be sent from your shared mailbox address (e.g. billing@yourclinic.co.uk), not from your personal email.
You can connect more than one shared mailbox. Click Connect Another Mailbox and repeat the steps above.
To remove a mailbox, go to Manage Mailboxes and click Disconnect next to the one you want to remove.
| Issue | What to try |
|---|---|
| "Sign in with Microsoft" doesn't open | Check your browser isn't blocking pop-ups. Try Chrome or Edge. |
| "Network error" when sending | Check your internet connection. Try sending to a different email address. Contact support if it persists. |
| Shared mailbox shows "Connected" but emails fail | The app will try to send via the practice's SMTP server as a fallback. If that also fails, check your SMTP settings. |
| Can't see the Email section | Make sure you're logged in with the right account. Clinicians see it on the clinician dashboard; billing staff see it on the accounts dashboard. |
integrations/migrations/. Always run showmigrations before running migrate in a new environment.localhost:1025), not real inboxes._get_app_only_token() acquires a fresh token on every call. For high-volume sending, consider adding token caching.