Feature: OAuth2 Gmail and Outlook email support (#7866)

This commit is contained in:
shamoon
2024-10-10 13:57:32 -07:00
committed by GitHub
parent dcc8d4046a
commit 2353f7c2db
30 changed files with 1110 additions and 121 deletions

View File

@@ -175,6 +175,7 @@ import {
download,
envelope,
envelopeAt,
envelopeAtFill,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -191,6 +192,7 @@ import {
folderFill,
funnel,
gear,
google,
grid,
gripVertical,
hash,
@@ -201,6 +203,7 @@ import {
link,
listTask,
listUl,
microsoft,
nodePlus,
pencil,
people,
@@ -279,6 +282,7 @@ const icons = {
download,
envelope,
envelopeAt,
envelopeAtFill,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -295,6 +299,7 @@ const icons = {
folderFill,
funnel,
gear,
google,
grid,
gripVertical,
hash,
@@ -305,6 +310,7 @@ const icons = {
link,
listTask,
listUl,
microsoft,
nodePlus,
pencil,
people,

View File

@@ -11,7 +11,7 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { IMAPSecurity } from 'src/app/data/mail-account'
import { IMAPSecurity, MailAccountType } from 'src/app/data/mail-account'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SettingsService } from 'src/app/services/settings.service'
@@ -82,6 +82,7 @@ describe('MailAccountEditDialogComponent', () => {
imap_port: 443,
imap_security: IMAPSecurity.SSL,
is_token: false,
account_type: MailAccountType.IMAP,
}
// success

View File

@@ -13,6 +13,16 @@
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Account</ng-container>
</button>
@if (gmailOAuthUrl) {
<a class="btn btn-sm btn-outline-secondary ms-2" [href]="gmailOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="google"></i-bs>&nbsp;<ng-container i18n>Connect Gmail Account</ng-container>
</a>
}
@if (outlookOAuthUrl) {
<a class="btn btn-sm btn-outline-secondary ms-2" [href]="outlookOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="microsoft"></i-bs>&nbsp;<ng-container i18n>Connect Outlook Account</ng-container>
</a>
}
</h4>
<ul class="list-group">
<li class="list-group-item">
@@ -27,7 +37,15 @@
@for (account of mailAccounts; track account) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div>
<div class="col d-flex align-items-center">
<button class="btn btn-link p-0 text-start" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">
{{account.name}}@switch (account.account_type) {
@case (MailAccountType.IMAP) {<i-bs name="envelope-at-fill" class="ms-2"></i-bs>}
@case (MailAccountType.Gmail_OAuth) {<i-bs name="google" class="ms-2"></i-bs>}
@case (MailAccountType.Outlook_OAuth) {<i-bs name="microsoft" class="ms-2"></i-bs>}
}
</button>
</div>
<div class="col d-flex align-items-center">{{account.imap_server}}</div>
<div class="col d-flex align-items-center d-none d-sm-block">{{account.username}}</div>
<div class="col">

View File

@@ -13,7 +13,7 @@ import {
import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { MailAccount } from 'src/app/data/mail-account'
import { MailAccount, MailAccountType } from 'src/app/data/mail-account'
import { MailRule } from 'src/app/data/mail-rule'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -44,10 +44,13 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SwitchComponent } from '../../common/input/switch/switch.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { By } from '@angular/platform-browser'
import { ActivatedRoute, convertToParamMap } from '@angular/router'
import { SettingsService } from 'src/app/services/settings.service'
const mailAccounts = [
{ id: 1, name: 'account1' },
{ id: 2, name: 'account2' },
{ id: 1, name: 'account1', account_type: MailAccountType.IMAP },
{ id: 2, name: 'account2', account_type: MailAccountType.IMAP },
{ id: 3, name: 'account3', accout_type: MailAccountType.Gmail_OAuth },
]
const mailRules = [
{ id: 1, name: 'rule1', owner: 1, account: 1, enabled: true },
@@ -62,6 +65,8 @@ describe('MailComponent', () => {
let modalService: NgbModal
let toastService: ToastService
let permissionsService: PermissionsService
let activatedRoute: ActivatedRoute
let settingsService: SettingsService
beforeEach(() => {
TestBed.configureTestingModule({
@@ -110,6 +115,9 @@ describe('MailComponent', () => {
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
permissionsService = TestBed.inject(PermissionsService)
activatedRoute = TestBed.inject(ActivatedRoute)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
@@ -348,4 +356,36 @@ describe('MailComponent', () => {
expect(patchSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should show success message when oauth account is connected', () => {
const queryParams = { oauth_success: '1' }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
completeSetup()
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should show error message when oauth account connect fails', () => {
const queryParams = { oauth_success: '0' }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
completeSetup()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should open account edit dialog if oauth account is connected', () => {
const queryParams = { oauth_success: '1', oauth_account: '3' }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
completeSetup()
component.oAuthAccountId = 3
const editSpy = jest.spyOn(component, 'editMailAccount')
component.ngOnInit()
expect(editSpy).toHaveBeenCalled()
})
})

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { MailAccount } from 'src/app/data/mail-account'
import { MailAccount, MailAccountType } from 'src/app/data/mail-account'
import { MailRule } from 'src/app/data/mail-rule'
import {
PermissionsService,
@@ -18,6 +18,9 @@ import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-ac
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ActivatedRoute } from '@angular/router'
@Component({
selector: 'pngx-mail',
@@ -28,17 +31,30 @@ export class MailComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
public MailAccountType = MailAccountType
mailAccounts: MailAccount[] = []
mailRules: MailRule[] = []
unsubscribeNotifier: Subject<any> = new Subject()
oAuthAccountId: number
public get gmailOAuthUrl(): string {
return this.settingsService.get(SETTINGS_KEYS.GMAIL_OAUTH_URL)
}
public get outlookOAuthUrl(): string {
return this.settingsService.get(SETTINGS_KEYS.OUTLOOK_OAUTH_URL)
}
constructor(
public mailAccountService: MailAccountService,
public mailRuleService: MailRuleService,
private toastService: ToastService,
private modalService: NgbModal,
public permissionsService: PermissionsService
public permissionsService: PermissionsService,
private settingsService: SettingsService,
private route: ActivatedRoute
) {
super()
}
@@ -50,6 +66,15 @@ export class MailComponent
.subscribe({
next: (r) => {
this.mailAccounts = r.results
console.log(this.mailAccounts, this.oAuthAccountId)
if (this.oAuthAccountId) {
this.editMailAccount(
this.mailAccounts.find(
(account) => account.id === this.oAuthAccountId
)
)
}
},
error: (e) => {
this.toastService.showError(
@@ -70,6 +95,27 @@ export class MailComponent
this.toastService.showError($localize`Error retrieving mail rules`, e)
},
})
this.route.queryParamMap.subscribe((params) => {
if (params.get('oauth_success')) {
const success = params.get('oauth_success') === '1'
if (success) {
this.toastService.showInfo($localize`OAuth2 authentication success`)
this.oAuthAccountId = parseInt(params.get('account_id'))
if (this.mailAccounts.length > 0) {
this.editMailAccount(
this.mailAccounts.find(
(account) => account.id === this.oAuthAccountId
)
)
}
} else {
this.toastService.showError(
$localize`OAuth2 authentication failed, see logs for details`
)
}
}
})
}
ngOnDestroy() {

View File

@@ -6,6 +6,12 @@ export enum IMAPSecurity {
STARTTLS = 3,
}
export enum MailAccountType {
IMAP = 1,
Gmail_OAuth = 2,
Outlook_OAuth = 3,
}
export interface MailAccount extends ObjectWithPermissions {
name: string
@@ -22,4 +28,8 @@ export interface MailAccount extends ObjectWithPermissions {
character_set?: string
is_token: boolean
account_type: MailAccountType
expiration?: string // Date
}

View File

@@ -64,6 +64,8 @@ export const SETTINGS_KEYS = {
SEARCH_DB_ONLY: 'general-settings:search:db-only',
SEARCH_FULL_TYPE: 'general-settings:search:more-link',
EMPTY_TRASH_DELAY: 'trash_delay',
GMAIL_OAUTH_URL: 'gmail_oauth_url',
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
}
export const SETTINGS: UiSetting[] = [
@@ -242,4 +244,14 @@ export const SETTINGS: UiSetting[] = [
type: 'number',
default: 30,
},
{
key: SETTINGS_KEYS.GMAIL_OAUTH_URL,
type: 'string',
default: null,
},
{
key: SETTINGS_KEYS.OUTLOOK_OAUTH_URL,
type: 'string',
default: null,
},
]

View File

@@ -4,7 +4,7 @@ import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { MailAccountService } from './mail-account.service'
import { IMAPSecurity } from 'src/app/data/mail-account'
import { IMAPSecurity, MailAccountType } from 'src/app/data/mail-account'
let httpTestingController: HttpTestingController
let service: MailAccountService
@@ -20,6 +20,7 @@ const mail_accounts = [
username: 'user',
password: 'pass',
is_token: false,
account_type: MailAccountType.IMAP,
},
{
name: 'Mail Account 2',
@@ -30,6 +31,7 @@ const mail_accounts = [
username: 'user',
password: 'pass',
is_token: false,
account_type: MailAccountType.IMAP,
},
{
name: 'Mail Account 3',
@@ -40,6 +42,7 @@ const mail_accounts = [
username: 'user',
password: 'pass',
is_token: false,
account_type: MailAccountType.IMAP,
},
]
@@ -55,20 +58,6 @@ describe(`Additional service tests for MailAccountService`, () => {
expect(req.request.method).toEqual('POST')
})
it('should support patchMany', () => {
subscription = service.patchMany(mail_accounts).subscribe()
mail_accounts.forEach((mail_account) => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${mail_account.id}/`
)
expect(req.request.method).toEqual('PATCH')
req.flush(mail_account)
})
httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
})
it('should support reload', () => {
service['reload']()
const req = httpTestingController.expectOne(

View File

@@ -1,6 +1,5 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { combineLatest, Observable } from 'rxjs'
import { tap } from 'rxjs/operators'
import { MailAccount } from 'src/app/data/mail-account'
import { AbstractPaperlessService } from './abstract-paperless-service'
@@ -34,15 +33,11 @@ export class MailAccountService extends AbstractPaperlessService<MailAccount> {
}
update(o: MailAccount) {
// Remove expiration from the object before updating
delete o.expiration
return super.update(o).pipe(tap(() => this.reload()))
}
patchMany(objects: MailAccount[]): Observable<MailAccount[]> {
return combineLatest(objects.map((o) => super.patch(o))).pipe(
tap(() => this.reload())
)
}
delete(o: MailAccount) {
return super.delete(o).pipe(tap(() => this.reload()))
}

View File

@@ -76,21 +76,6 @@ const mail_rules = [
commonAbstractPaperlessServiceTests(endpoint, MailRuleService)
describe(`Additional service tests for MailRuleService`, () => {
it('should support patchMany', () => {
subscription = service.patchMany(mail_rules).subscribe()
mail_rules.forEach((mail_rule) => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/`
)
expect(req.request.method).toEqual('PATCH')
req.flush(mail_rule)
})
const reloadReq = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
reloadReq.flush({ results: mail_rules })
})
it('should support reload', () => {
service['reload']()
const req = httpTestingController.expectOne(

View File

@@ -37,12 +37,6 @@ export class MailRuleService extends AbstractPaperlessService<MailRule> {
return super.update(o).pipe(tap(() => this.reload()))
}
patchMany(objects: MailRule[]): Observable<MailRule[]> {
return combineLatest(objects.map((o) => super.patch(o))).pipe(
tap(() => this.reload())
)
}
delete(o: MailRule) {
return super.delete(o).pipe(tap(() => this.reload()))
}