GASを使ってGmailのメールからBacklogの課題を起票する方法


# やりたいこと
1. Gmailで受信したメールの内、特定の条件に当てはまるメールの内容でBacklogの課題を起票したい。
1. 既に登録済課題のメールに対する返信メールは、課題のコメントに登録したい。

# メールからBacklogの課題を起票する方法

## 1. GmailのスレッドのIDを格納するカスタム属性を作成する
課題登録対象のプロジェクトで課題のカスタム属性を作成します。

- カスタム属性の作成方法 : [カスタム属性の設定方法 &ndash; Backlog ヘルプセンター](https://support-ja.backlog.com/hc/ja/articles/360035640274-%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E5%B1%9E%E6%80%A7%E3%81%AE%E8%A8%AD%E5%AE%9A%E6%96%B9%E6%B3%95)

作成したカスタム属性には、課題を起票する際にGmailのスレッドのIDを設定します。
これにより、返信メールがどの課題に紐づくメールなのかをわかるようにします。

- スレッドのIDは`GmailThread`クラスの`getId()`メソッドで取得できます : [getId() - Class GmailThread &nbsp;|&nbsp; Apps Script &nbsp;|&nbsp; Google Developers](https://developers.google.com/apps-script/reference/gmail/gmail-thread#getId())

カスタム属性をBacklog APIで利用する際に、カスタム属性のIDが必要になります。
事前に、作成したカスタム属性のIDをAPIで取得して定数に定義しておきます。

- 利用するAPI : [カスタム属性一覧の取得 | Backlog Developer API | Nulab](https://developer.nulab.com/ja/docs/backlog/api/2/get-custom-field-list/#)

```js
/** カスタム属性のID */
const backlog_customFieldId = '{カスタム属性のID}';
/** カスタム属性のAPI呼出し時パラメータ名. */
const customField_param = 'customField_' + backlog_customFieldId;
```

## 2. 条件に一致するGmailを取得する
課題にしたいメールの検索条件を定数で定義しておきます。
何度も試して「必要なメールが検索出来る」「不要なメールが入り込まない」条件となるように調整します。

- 条件の指定方法 : [Gmail で使用できる検索演算子 - Gmail ヘルプ](https://support.google.com/mail/answer/7190?topic=41&hl=ja)

```js
/** メールの検索条件 */
const searchQuery = 'list:{メーリングリストのアドレス} subject:{件名に含まれる文字} -{除外する文字}'; 
```

定期的に実行させるので、その実行タイミングに合わせて検索対象とする時間を処理の中で指定します。
Gmailの検索において日付ではなく時間を指定したいので、UNIX時間に時間を変換して指定します。

- 参考 : [Google Apps ScriptでGmailの情報を取得する際に、受信日時を細かく指定する方法。 - Qiita](https://qiita.com/3mc/items/39b2c8241c6b52811ad2)

```js
  let after = new Date();
  // 今から6時間前以降のメールを検索する
  after.setHours(after.getHours() - 6);
  // Unix時間(ミリ秒)に変換して秒にする
  let unixTime = Math.floor(after.getTime()/1000);

  /** @type {string} Gmailの検索条件. */
  let query = searchQuery + ' after:' + unixTime.toString();
```

GmailAppクラスのメソッドを利用して対象のメールを検索するのですが、メールはスレッドで取得されるので検索条件に指定した時間から外れたメールも入り込んできます。
そのため、検索条件の時間を使って対象のメールだけになるようにフィルタリングします。

- 参考 : [Class GmailApp &nbsp;|&nbsp; Apps Script &nbsp;|&nbsp; Google Developers](https://developers.google.com/apps-script/reference/gmail/gmail-app)

```js
  let mails = [];
  messages.forEach(function(thread) {
    /** @type {Array.<GmailMessage>} 1つのスレッドに紐づくメールを格納する配列. */
    let threadMails = [];
    thread.forEach(function(message) {
      if (message.getDate() > after.getTime()) {
        // 条件となる日付以降のメールを配列に格納する.
        threadMails.push(message);
      }
    });
    if (threadMails.length > 0) {
      mails.push(threadMails);
    }
  });
```

## 2. スレッドの1件目のメールを判定する
スレッドの1件目のメールが、「既に登録された課題に紐づく返信メール」なのか、「まだ課題登録されていないメール」なのかを判定して処理します。
判定に利用するのは、課題のカスタム属性に登録されたスレッドのIDです。
スレッドのIDで課題を検索して、課題が登録済であるかを判定します。

- 使用するAPI : [課題一覧の取得 | Backlog Developer API | Nulab](https://developer.nulab.com/ja/docs/backlog/api/2/get-issue-list/#)
- APIの呼び出しに使用するクラス : [Class UrlFetchApp &nbsp;|&nbsp; Apps Script &nbsp;|&nbsp; Google Developers](https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app)

```js
/**
 * スレッドIDで課題を検索する.
 * @param {string} GmailのスレッドID.
 * @return {string} 課題キー.
 */
function search_issue_key {
  let issueKey = '';

  /** 課題一覧の取得のAPIのエンドポイント. */
  let endpoint = [backlog_url + '/api/v2/issues?apiKey=' + backlog_apikey];
  endpoint.push('projectId[]=' + backlog_projectId);
  endpoint.push('issueTypeId[]=' + backlog_issueTypeId);
  endpoint.push(customField_param + '=' + threadId);
  /** @type {HTTPResponse} APIの返却データ. */
  let httpResponse = UrlFetchApp.fetch(endpoint.join('&'));

  if (httpResponse.getResponseCode() < 400) {
    let issues = JSON.parse(httpResponse.getContentText());
    if (issues.length > 0) {
      // 1件目の課題キーを取得する.
      issueKey = issues[0].issueKey;
    }
  }
  return issueKey;
}
```

## 3. (課題がない場合)課題を新規に起票する

課題が登録されていなかったら、課題を新規に起票します。
登録課題の種別や優先度などはあらかじめIDをAPIで取得しておいて定数で定義しておきます。

```js
/** 登録する課題の種別 */
const backlog_issueTypeId = '{種別のID}';
/** 登録する課題の優先度(中) */
const backlog_priorityId = '{優先度のID}';
```

課題の件名に「メールの件名」、詳細欄に「メールの本文と送信者と日時」、カスタム属性に「メールのスレッドのID」を登録します。

- 使用するAPI : [課題の追加 | Backlog Developer API | Nulab](https://developer.nulab.com/ja/docs/backlog/api/2/add-issue/#)

```js
/**
 * 課題を追加する.
 * @param {string} summary 課題の件名.
 * @param {string} description 課題の詳細.
 * @param {string} threadId 課題のカスタム属性であるthreadIdの値.
 */
function post_issue(summary, description, threadId) {
  /** 課題登録のAPIのエンドポイント. */
  let endpoint = backlog_url + '/api/v2/issues?apiKey=' + backlog_apikey;

  let payload = {
    'projectId' : backlog_projectId,
    'issueTypeId' : backlog_issueTypeId,
    'priorityId' : backlog_priorityId,
    'summary' : summary,
    'description' : description
  };
  payload[customField_param] = threadId;

  let params = {
    'method' : 'POST',
    'payload' : payload
  };

  let issueKey = null;
  /** @type {HTTPResponse} */
  let httpResponse = UrlFetchApp.fetch(endpoint, params);
  if (httpResponse.getResponseCode() < 400) {
    issueKey = JSON.parse(httpResponse.getContentText()).issueKey;
  }
  return issueKey;
}
```

## 4. (課題がある場合)課題にコメントを登録する
課題が登録されていたら、「同じスレッドのIDが登録されている課題」のコメントにメールの内容を登録します。

- 使用するAPI : [課題コメントの追加 | Backlog Developer API | Nulab](https://developer.nulab.com/ja/docs/backlog/api/2/add-comment/#)

```js
/**
 * 課題にコメントを追加する.
 * @param {string} issueKey 課題キー.
 * @param {GmailMessage} message コメントとして登録するメール.
 */
function post_comment(issueKey, message) {
  /** 課題コメントの追加のAPIのエンドポイント. */
  let endpoint = backlog_url + '/api/v2/issues/' + issueKey + '/comments?apiKey=' + backlog_apikey;

  let payload = {
    'content' : create_comment(message.getDate(), message.getFrom(), message.getPlainBody())
  };

  /** @type {Array.<GmailAttachment>} メッセージのBlob添付ファイルの配列. */
  let attachments = message.getAttachments();
  let i = 0;
  attachments.forEach(function(attachment) {
    // 添付ファイルがある場合 : Backlogに添付ファイルを送信して発行されたIDをペーロードに追加する.
    payload['attachmentId[' + i + ']'] = post_attachment(attachment);
  })

  let params = {
    'method' : 'POST',
    'payload' : payload
  };
  UrlFetchApp.fetch(endpoint, params);
}
```

ここで、添付ファイルをコメントで追加する際にかなり苦戦しました。
添付ファイルが、日本語のファイル名の場合に文字化けしてしまったのです。
その対応について以下の記事に記録していますのでこの記事では添付ファイルについては割愛します。
[GASでGmailの添付ファイルをBacklogに送信したらファイル名が文字化けした時の対応方法 - Qiita](https://qiita.com/chovin/items/2cdfcbaa381faf8a1602)

## 5. (スレッドにメールが2件以上がある場合)課題にコメントを登録する
スレッドにメールが2件以上がある場合は、1件目のメールで登録した課題のコメントとして登録していきます。

```js
    if (messages.length > 1) {
      for (let i = 1; i < messages.length; i++) {
        // スレッドにメールが2件以上がある場合 : コメントとして登録する.
        post_comment(issueKey, messages[i]);
      }
    }
```

## 6. GASのコードを定期実行する
「左側のメニュー > [トリガー] > [トリガーを追加]」でトリガーを設定して定期的に実行されるように設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743295/5bee5366-e794-bf0e-07e4-3cc56804ba8b.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743295/0fee128d-df13-8f9f-123b-12502ae533ee.png)

# ソースコードの全て

```js
/** メールの検索条件 */
const searchQuery = 'list:{メーリングリストのアドレス} subject:{件名に含まれる文字} -{除外する文字}'; 
/** BacklogのURL */
const backlog_url = '{BacklogのURL}';
/** BacklogのAPI KEY */
const backlog_apikey = '{BacklogのAPIキー}';
/** 課題を登録するプロジェクトID */
const backlog_projectId = '{プロジェクトのID}';
/** 登録する課題の種別 */
const backlog_issueTypeId = '{種別のID}';
/** 登録する課題の優先度(中) */
const backlog_priorityId = '{優先度のID}';
/** カスタム属性のID */
const backlog_customFieldId = '{カスタム属性のID}';
/** カスタム属性のAPI呼出し時パラメータ名. */
const customField_param = 'customField_' + backlog_customFieldId;

function gmail_to_backlog() {
  /** @type {Array.<GmailMessage[]>} クエリに一致するGmailの配列. */
  let gmailMessages = get_Gmail();
  gmailMessages.forEach(function(messages){
    let issueKey = post_first_mail(messages[0]);
    if (messages.length > 1) {
      for (let i = 1; i < messages.length; i++) {
        // スレッドにメールが2件以上がある場合 : コメントとして登録する.
        post_comment(issueKey, messages[i]);
      }
    }
  });
}


/**
 * 条件に一致するGmailを取得する
 * @return {Array.<GmailMessage[]>} 条件に一致するGmailの配列、外側の配列の各項目はスレッドに対応し、内側の配列にはそのスレッドのメッセージが含まれる.
 */
function get_Gmail() {
  let after = new Date();
  // 今から6時間前以降のメールを検索する
  after.setHours(after.getHours() - 6);
  // Unix時間(ミリ秒)に変換して秒にする
  let unixTime = Math.floor(after.getTime()/1000);

  /** @type {string} Gmailの検索条件. */
  let query = searchQuery + ' after:' + unixTime.toString();
  /** @type {Array.<GmailThread>} クエリに一致したGmailスレッド. */
  let threads = GmailApp.search(query);
  /** @type {Array.<GmailMessage[]>} メッセージの配列の配列で、外側の配列の各項目はスレッドに対応し、内側の配列にはそのスレッドのメッセージが含まれる. */
  let messages = GmailApp.getMessagesForThreads(threads);

  let mails = [];
  messages.forEach(function(thread) {
    /** @type {Array.<GmailMessage>} 1つのスレッドに紐づくメールを格納する配列. */
    let threadMails = [];
    thread.forEach(function(message) {
      if (message.getDate() > after.getTime()) {
        // 条件となる日付以降のメールを配列に格納する.
        threadMails.push(message);
      }
    });
    if (threadMails.length > 0) {
      mails.push(threadMails);
    }
  });

  return mails;
}

/**
 * スレッドの最初のメールをBacklogに登録する.
 * @param {GmailMessage} スレッドの最初のメール.
 * @return {string} 登録対象の課題キー.
 */
function post_first_mail(first) {
  // 対象の課題キーを取得する
  let threadId = first.getThread().getId();
  // スレッドIDで課題を検索する.
  let issueKey = search_issue_key;
  if (issueKey) {
    // 登録済みの課題がある場合 : 課題のコメントを登録する.
    post_comment(issueKey, first);
  } else {
    // 登録済みの課題がない場合 : 課題を登録する.
    /** @type {string} 登録する課題の詳細. */
    let description = create_comment(first.getDate(), first.getFrom(), first.getPlainBody());
    issueKey = post_issue(first.getSubject(), description, threadId);
  }
  return issueKey;
}


/**
 * スレッドIDで課題を検索する.
 * @param {string} GmailのスレッドID.
 * @return {string} 課題キー.
 */
function search_issue_key {
  let issueKey = '';

  /** 課題一覧の取得のAPIのエンドポイント. */
  let endpoint = [backlog_url + '/api/v2/issues?apiKey=' + backlog_apikey];
  endpoint.push('projectId[]=' + backlog_projectId);
  endpoint.push('issueTypeId[]=' + backlog_issueTypeId);
  endpoint.push(customField_param + '=' + threadId);
  /** @type {HTTPResponse} APIの返却データ. */
  let httpResponse = UrlFetchApp.fetch(endpoint.join('&'));

  if (httpResponse.getResponseCode() < 400) {
    let issues = JSON.parse(httpResponse.getContentText());
    if (issues.length > 0) {
      // 1件目の課題キーを取得する.
      issueKey = issues[0].issueKey;
    }
  }
  return issueKey;
}


/**
 * 課題の詳細やコメントの内容を作成する.
 * @param {string} date 日付と時刻.
 * @param {string} address 送信者.
 * @param {string} body HTMLフォーマットなしの本文コンテンツ.
 */
function create_comment(date, address, body) {
  let comment = [];
  comment.push('- 日時 : ' + date);
  comment.push('- From : ' + address);
  comment.push('\n');
  // Backlog入力文字数制限 : 詳細:100,000文字、コメント:50,000文字
  // 1000文字以上はメールの引用部分である可能性が高いので切り捨てる
  comment.push(body.substr(0, 1000));
  return comment.join('\n');
}


/**
 * 課題を追加する.
 * @param {string} summary 課題の件名.
 * @param {string} description 課題の詳細.
 * @param {string} threadId 課題のカスタム属性であるthreadIdの値.
 */
function post_issue(summary, description, threadId) {
  /** 課題登録のAPIのエンドポイント. */
  let endpoint = backlog_url + '/api/v2/issues?apiKey=' + backlog_apikey;

  let payload = {
    'projectId' : backlog_projectId,
    'issueTypeId' : backlog_issueTypeId,
    'priorityId' : backlog_priorityId,
    'summary' : summary,
    'description' : description
  };
  payload[customField_param] = threadId;

  let params = {
    'method' : 'POST',
    'payload' : payload
  };

  let issueKey = null;
  /** @type {HTTPResponse} */
  let httpResponse = UrlFetchApp.fetch(endpoint, params);
  if (httpResponse.getResponseCode() < 400) {
    issueKey = JSON.parse(httpResponse.getContentText()).issueKey;
  }
  return issueKey;
}


/**
 * 課題にコメントを追加する.
 * @param {string} issueKey 課題キー.
 * @param {GmailMessage} message コメントとして登録するメール.
 */
function post_comment(issueKey, message) {
  /** 課題コメントの追加のAPIのエンドポイント. */
  let endpoint = backlog_url + '/api/v2/issues/' + issueKey + '/comments?apiKey=' + backlog_apikey;

  let payload = {
    'content' : create_comment(message.getDate(), message.getFrom(), message.getPlainBody())
  };

  /** @type {Array.<GmailAttachment>} メッセージのBlob添付ファイルの配列. */
  let attachments = message.getAttachments();
  let i = 0;
  attachments.forEach(function(attachment) {
    // 添付ファイルがある場合 : Backlogに添付ファイルを送信して発行されたIDをペーロードに追加する.
    payload['attachmentId[' + i + ']'] = post_attachment(attachment);
  })

  let params = {
    'method' : 'POST',
    'payload' : payload
  };
  UrlFetchApp.fetch(endpoint, params);
}


/**
 * コメントに添付するファイルを送信し、添付ファイルに発行されたIDを取得する.
 * @param {GmailAttachment} attachment メッセージのBlob添付ファイル.
 * @return {string} 添付ファイルに発行されたID.
 */
function post_attachment(attachment) {
  /** 添付ファイル送信のAPIのエンドポイント. */
  let endpoint = backlog_url + '/api/v2/space/attachment?apiKey=' + backlog_apikey;
  /** @type {string} 添付ファイル情報の境界となる文字. */
  let boundary = 'boundary';
  /** @type {string} 改行文字. */
  let lineBreak = '\r\n';
  /** @type {Array.<Byte>} 添付ファイルのBlobの基となるバイト配列. */
  let blobByte = Utilities.newBlob(
    // 「Content-Disposition」「Content-Type」を指定する.
    '--' + boundary + lineBreak
    + 'Content-Disposition: form-data; name="file"; filename="' + attachment.getName() + '"' + lineBreak
    + 'Content-Type: ' + attachment.getContentType() + lineBreak + lineBreak
  ).getBytes().concat(
    // 添付ファイル本体を追加する.
    attachment.getBytes()
  ).concat(
    // 最後の境界文字を追加する.
    Utilities.newBlob(lineBreak + '--' + boundary + '--').getBytes()
  );
  let blob = Utilities.newBlob('').setBytes(blobByte);

  let params = {
    method : 'POST',
    contentType: 'multipart/form-data; boundary=' + boundary,
    payload : {
      file : blob
    }
  };

  let id = null;
  /** @type {HTTPResponse} */
  let httpResponse = UrlFetchApp.fetch(endpoint, params);
  if (httpResponse.getResponseCode() < 400) {
    let contentText = httpResponse.getContentText();
    console.log(contentText);
    id = JSON.parse(contentText).id;
  }
  return id.toString();
}

```